mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50:57 +08:00
feat: add xyz dex balance calculation, market data providers, and UI improvements
- Fix xyz dex balance calculation (use marginSummary for isolated margin) - Add Alpaca provider for US stocks market data - Add TwelveData provider for forex & metals market data - Add Hyperliquid kline provider - Centralize API keys in config system - Add builder fee for order routing - Improve chart UI with compact design - Fix position history fee display precision - Add comprehensive balance calculation tests
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
||||
calculateBollingerBands,
|
||||
type Kline,
|
||||
} from '../utils/indicators'
|
||||
import { Settings, TrendingUp, BarChart2 } from 'lucide-react'
|
||||
import { Settings, BarChart2 } from 'lucide-react'
|
||||
|
||||
// 订单接口定义
|
||||
interface OrderMarker {
|
||||
@@ -49,17 +49,37 @@ interface IndicatorConfig {
|
||||
params?: any
|
||||
}
|
||||
|
||||
// 热门币种
|
||||
const POPULAR_SYMBOLS = [
|
||||
'BTCUSDT',
|
||||
'ETHUSDT',
|
||||
'SOLUSDT',
|
||||
'BNBUSDT',
|
||||
'XRPUSDT',
|
||||
'DOGEUSDT',
|
||||
'ADAUSDT',
|
||||
'AVAXUSDT',
|
||||
]
|
||||
// 获取成交额货币单位
|
||||
const getQuoteUnit = (exchange: string): string => {
|
||||
if (['alpaca'].includes(exchange)) {
|
||||
return 'USD'
|
||||
}
|
||||
if (['forex', 'metals'].includes(exchange)) {
|
||||
return '' // 外汇/贵金属没有真实成交量
|
||||
}
|
||||
return 'USDT' // 加密货币默认 USDT
|
||||
}
|
||||
|
||||
// 获取成交量数量单位
|
||||
const getBaseUnit = (exchange: string, symbol: string): string => {
|
||||
if (['alpaca'].includes(exchange)) {
|
||||
return '股'
|
||||
}
|
||||
if (['forex', 'metals'].includes(exchange)) {
|
||||
return ''
|
||||
}
|
||||
// 加密货币:从 symbol 提取基础资产
|
||||
const base = symbol.replace(/USDT$|USD$|BUSD$/, '')
|
||||
return base || '个'
|
||||
}
|
||||
|
||||
// 格式化大数字
|
||||
const formatVolume = (value: number): string => {
|
||||
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'B'
|
||||
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'M'
|
||||
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'K'
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
export function AdvancedChart({
|
||||
symbol = 'BTCUSDT',
|
||||
@@ -67,9 +87,12 @@ export function AdvancedChart({
|
||||
traderID,
|
||||
height = 550,
|
||||
exchange = 'binance', // 默认使用 binance
|
||||
onSymbolChange,
|
||||
onSymbolChange: _onSymbolChange, // Available for future use
|
||||
}: AdvancedChartProps) {
|
||||
void _onSymbolChange // Prevent unused warning
|
||||
const { language } = useLanguage()
|
||||
const quoteUnit = getQuoteUnit(exchange)
|
||||
const baseUnit = getBaseUnit(exchange, symbol)
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<IChartApi | null>(null)
|
||||
const candlestickSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
|
||||
@@ -77,6 +100,7 @@ export function AdvancedChart({
|
||||
const indicatorSeriesRef = useRef<Map<string, ISeriesApi<any>>>(new Map())
|
||||
const seriesMarkersRef = useRef<any>(null) // Markers primitive for v5
|
||||
const currentMarkersDataRef = useRef<any[]>([]) // 存储当前的标记数据
|
||||
const klineDataRef = useRef<Map<number, { volume: number; quoteVolume: number }>>(new Map()) // 存储 kline 额外数据
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -86,6 +110,17 @@ export function AdvancedChart({
|
||||
const [tooltipData, setTooltipData] = useState<any>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 行情统计数据(当前K线)
|
||||
const [marketStats, setMarketStats] = useState<{
|
||||
price: number
|
||||
priceChange: number
|
||||
priceChangePercent: number
|
||||
high: number
|
||||
low: number
|
||||
volume: number // 数量(BTC/股数)
|
||||
quoteVolume: number // 成交额(USDT/USD)
|
||||
} | null>(null)
|
||||
|
||||
// 指标配置
|
||||
const [indicators, setIndicators] = useState<IndicatorConfig[]>([
|
||||
{ id: 'volume', name: 'Volume', enabled: true, color: '#3B82F6' },
|
||||
@@ -109,14 +144,28 @@ export function AdvancedChart({
|
||||
throw new Error('Failed to fetch kline data')
|
||||
}
|
||||
|
||||
return result.data.map((candle: any) => ({
|
||||
// 转换数据格式
|
||||
const rawData = result.data.map((candle: any) => ({
|
||||
time: Math.floor(candle.openTime / 1000) as UTCTimestamp,
|
||||
open: candle.open,
|
||||
high: candle.high,
|
||||
low: candle.low,
|
||||
close: candle.close,
|
||||
volume: candle.volume,
|
||||
volume: candle.volume, // 数量(BTC/股数)
|
||||
quoteVolume: candle.quoteVolume, // 成交额(USDT/USD)
|
||||
}))
|
||||
|
||||
// 按时间排序并去重(lightweight-charts 要求数据按时间升序且无重复)
|
||||
const sortedData = rawData.sort((a: any, b: any) => a.time - b.time)
|
||||
const dedupedData = sortedData.filter((item: any, index: number, arr: any[]) =>
|
||||
index === 0 || item.time !== arr[index - 1].time
|
||||
)
|
||||
|
||||
if (rawData.length !== dedupedData.length) {
|
||||
console.warn('[AdvancedChart] Removed', rawData.length - dedupedData.length, 'duplicate klines')
|
||||
}
|
||||
|
||||
return dedupedData
|
||||
} catch (err) {
|
||||
console.error('[AdvancedChart] Error fetching kline:', err)
|
||||
throw err
|
||||
@@ -383,12 +432,18 @@ export function AdvancedChart({
|
||||
}
|
||||
|
||||
const candleData = data as any
|
||||
|
||||
// 从存储的数据中获取 volume 和 quoteVolume
|
||||
const klineExtra = klineDataRef.current.get(param.time as number) || { volume: 0, quoteVolume: 0 }
|
||||
|
||||
setTooltipData({
|
||||
time: param.time,
|
||||
open: candleData.open,
|
||||
high: candleData.high,
|
||||
low: candleData.low,
|
||||
close: candleData.close,
|
||||
volume: klineExtra.volume,
|
||||
quoteVolume: klineExtra.quoteVolume,
|
||||
x: param.point.x,
|
||||
y: param.point.y,
|
||||
})
|
||||
@@ -405,11 +460,25 @@ export function AdvancedChart({
|
||||
// 当 symbol 或 interval 改变时,重置初始加载标志(以便自动适配新数据)
|
||||
isInitialLoadRef.current = true
|
||||
|
||||
const loadData = async () => {
|
||||
// 清除旧的标记数据,避免旧数据影响新图表
|
||||
currentMarkersDataRef.current = []
|
||||
if (seriesMarkersRef.current) {
|
||||
try {
|
||||
seriesMarkersRef.current.setMarkers([])
|
||||
} catch (e) {
|
||||
// 忽略错误,稍后会重新创建
|
||||
}
|
||||
seriesMarkersRef.current = null
|
||||
}
|
||||
|
||||
const loadData = async (isRefresh = false) => {
|
||||
if (!candlestickSeriesRef.current) return
|
||||
|
||||
console.log('[AdvancedChart] Loading data for', symbol, interval)
|
||||
setLoading(true)
|
||||
console.log('[AdvancedChart] Loading data for', symbol, interval, isRefresh ? '(refresh)' : '')
|
||||
// 只在首次加载时显示 loading,刷新时不显示避免闪烁
|
||||
if (!isRefresh) {
|
||||
setLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
@@ -418,6 +487,43 @@ export function AdvancedChart({
|
||||
console.log('[AdvancedChart] Loaded', klineData.length, 'klines')
|
||||
candlestickSeriesRef.current.setData(klineData)
|
||||
|
||||
// 存储 volume/quoteVolume 数据供 tooltip 使用
|
||||
klineDataRef.current.clear()
|
||||
klineData.forEach((k: any) => {
|
||||
klineDataRef.current.set(k.time, { volume: k.volume || 0, quoteVolume: k.quoteVolume || 0 })
|
||||
})
|
||||
|
||||
// 1.5 计算行情统计数据
|
||||
if (klineData.length > 1) {
|
||||
const latestKline = klineData[klineData.length - 1]
|
||||
const prevKline = klineData[klineData.length - 2]
|
||||
|
||||
// 涨跌幅:当前K线收盘价 vs 前一根K线收盘价
|
||||
const priceChange = latestKline.close - prevKline.close
|
||||
const priceChangePercent = (priceChange / prevKline.close) * 100
|
||||
|
||||
setMarketStats({
|
||||
price: latestKline.close,
|
||||
priceChange,
|
||||
priceChangePercent,
|
||||
high: latestKline.high,
|
||||
low: latestKline.low,
|
||||
volume: latestKline.volume || 0,
|
||||
quoteVolume: latestKline.quoteVolume || 0,
|
||||
})
|
||||
} else if (klineData.length === 1) {
|
||||
const latestKline = klineData[0]
|
||||
setMarketStats({
|
||||
price: latestKline.close,
|
||||
priceChange: 0,
|
||||
priceChangePercent: 0,
|
||||
high: latestKline.high,
|
||||
low: latestKline.low,
|
||||
volume: latestKline.volume || 0,
|
||||
quoteVolume: latestKline.quoteVolume || 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 显示成交量
|
||||
if (volumeSeriesRef.current) {
|
||||
const volumeEnabled = indicators.find(i => i.id === 'volume')?.enabled
|
||||
@@ -561,12 +667,12 @@ export function AdvancedChart({
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
loadData(false) // 首次加载
|
||||
|
||||
// 实时自动刷新 (5秒更新一次)
|
||||
const refreshInterval = setInterval(loadData, 5000)
|
||||
const refreshInterval = setInterval(() => loadData(true), 5000)
|
||||
return () => clearInterval(refreshInterval)
|
||||
}, [symbol, interval, traderID, indicators])
|
||||
}, [symbol, interval, traderID, exchange])
|
||||
|
||||
// 单独处理订单标记的显示/隐藏,避免重新加载数据
|
||||
useEffect(() => {
|
||||
@@ -663,116 +769,93 @@ export function AdvancedChart({
|
||||
border: '1px solid rgba(43, 49, 57, 0.5)',
|
||||
}}
|
||||
>
|
||||
{/* 标题栏 - 专业化设计 */}
|
||||
{/* Compact Professional Header */}
|
||||
<div
|
||||
className="px-4 py-2.5 space-y-2"
|
||||
style={{ borderBottom: '1px solid #2B3139', background: 'linear-gradient(180deg, #1A1E23 0%, #0B0E11 100%)' }}
|
||||
className="flex items-center justify-between px-4 py-2"
|
||||
style={{ borderBottom: '1px solid rgba(43, 49, 57, 0.6)', background: '#0D1117' }}
|
||||
>
|
||||
{/* 第一行:标题和控制按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-5 h-5 text-yellow-400" />
|
||||
<h3 className="text-base font-bold" style={{ color: '#F0B90B' }}>
|
||||
{symbol}
|
||||
</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded" style={{ background: '#2B3139', color: '#848E9C' }}>
|
||||
{interval}
|
||||
</span>
|
||||
{/* 交易所标识 */}
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded font-medium uppercase"
|
||||
style={{
|
||||
background: exchange === 'binance' ? 'rgba(243, 186, 47, 0.15)' :
|
||||
exchange === 'bybit' ? 'rgba(247, 147, 26, 0.15)' :
|
||||
exchange === 'okx' ? 'rgba(0, 180, 255, 0.15)' :
|
||||
exchange === 'bitget' ? 'rgba(0, 212, 170, 0.15)' :
|
||||
exchange === 'hyperliquid' ? 'rgba(80, 227, 194, 0.15)' :
|
||||
exchange === 'aster' ? 'rgba(138, 43, 226, 0.15)' :
|
||||
'rgba(255, 255, 255, 0.1)',
|
||||
color: exchange === 'binance' ? '#F3BA2F' :
|
||||
exchange === 'bybit' ? '#F7931A' :
|
||||
exchange === 'okx' ? '#00B4FF' :
|
||||
exchange === 'bitget' ? '#00D4AA' :
|
||||
exchange === 'hyperliquid' ? '#50E3C2' :
|
||||
exchange === 'aster' ? '#8A2BE2' :
|
||||
'#848E9C',
|
||||
border: `1px solid ${
|
||||
exchange === 'binance' ? 'rgba(243, 186, 47, 0.3)' :
|
||||
exchange === 'bybit' ? 'rgba(247, 147, 26, 0.3)' :
|
||||
exchange === 'okx' ? 'rgba(0, 180, 255, 0.3)' :
|
||||
exchange === 'bitget' ? 'rgba(0, 212, 170, 0.3)' :
|
||||
exchange === 'hyperliquid' ? 'rgba(80, 227, 194, 0.3)' :
|
||||
exchange === 'aster' ? 'rgba(138, 43, 226, 0.3)' :
|
||||
'rgba(255, 255, 255, 0.2)'
|
||||
}`
|
||||
}}
|
||||
title={['bitget', 'lighter'].includes(exchange?.toLowerCase() || '')
|
||||
? 'Data source: Binance (fallback)' : undefined}
|
||||
>
|
||||
{exchange}
|
||||
{['bitget', 'lighter'].includes(exchange?.toLowerCase() || '') && (
|
||||
<span className="ml-1 text-[9px] opacity-60">*</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Left: Symbol Info + Price */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Symbol & Interval */}
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && (
|
||||
<div className="text-xs px-2 py-1 rounded" style={{ background: '#2B3139', color: '#F0B90B' }}>
|
||||
{language === 'zh' ? '更新中...' : 'Updating...'}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowIndicatorPanel(!showIndicatorPanel)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all"
|
||||
<span className="text-sm font-bold text-white">{symbol}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[#1F2937] text-gray-400">{interval}</span>
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium uppercase"
|
||||
style={{
|
||||
background: showIndicatorPanel ? 'rgba(240, 185, 11, 0.15)' : 'rgba(255, 255, 255, 0.05)',
|
||||
color: showIndicatorPanel ? '#F0B90B' : '#848E9C',
|
||||
border: `1px solid ${showIndicatorPanel ? 'rgba(240, 185, 11, 0.3)' : '#2B3139'}`,
|
||||
background: exchange === 'hyperliquid' ? 'rgba(80, 227, 194, 0.1)' : 'rgba(243, 186, 47, 0.1)',
|
||||
color: exchange === 'hyperliquid' ? '#50E3C2' : '#F3BA2F',
|
||||
}}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
<span>{language === 'zh' ? '指标' : 'Indicators'}</span>
|
||||
</button>
|
||||
|
||||
{/* 订单标记开关 */}
|
||||
<button
|
||||
onClick={() => setShowOrderMarkers(!showOrderMarkers)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all"
|
||||
style={{
|
||||
background: showOrderMarkers ? 'rgba(240, 185, 11, 0.15)' : 'rgba(255, 255, 255, 0.05)',
|
||||
color: showOrderMarkers ? '#F0B90B' : '#848E9C',
|
||||
border: `1px solid ${showOrderMarkers ? 'rgba(240, 185, 11, 0.3)' : '#2B3139'}`,
|
||||
}}
|
||||
title={language === 'zh' ? '切换订单标记显示' : 'Toggle Order Markers'}
|
||||
>
|
||||
<span className="font-bold text-[11px]">B/S</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行:热门币种快速选择 */}
|
||||
{onSymbolChange && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium mr-1" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '快速选择:' : 'Quick:'}
|
||||
{exchange?.toUpperCase()}
|
||||
</span>
|
||||
{POPULAR_SYMBOLS.map((sym) => (
|
||||
<button
|
||||
key={sym}
|
||||
onClick={() => onSymbolChange(sym)}
|
||||
className="px-2 py-1 rounded text-[11px] font-medium transition-all"
|
||||
</div>
|
||||
|
||||
{/* Price Display */}
|
||||
{marketStats && (
|
||||
<div className="flex items-center gap-3 pl-3 border-l border-[#2B3139]">
|
||||
<span
|
||||
className="text-base font-bold tabular-nums"
|
||||
style={{ color: marketStats.priceChange >= 0 ? '#10B981' : '#EF4444' }}
|
||||
>
|
||||
{marketStats.price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: exchange === 'forex' || exchange === 'metals' ? 4 : 2
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-medium px-1.5 py-0.5 rounded tabular-nums"
|
||||
style={{
|
||||
background: symbol === sym ? 'rgba(240, 185, 11, 0.2)' : 'rgba(43, 49, 57, 0.5)',
|
||||
color: symbol === sym ? '#F0B90B' : '#848E9C',
|
||||
border: `1px solid ${symbol === sym ? 'rgba(240, 185, 11, 0.4)' : 'transparent'}`,
|
||||
background: marketStats.priceChange >= 0 ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||||
color: marketStats.priceChange >= 0 ? '#10B981' : '#EF4444',
|
||||
}}
|
||||
>
|
||||
{sym.replace('USDT', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{marketStats.priceChange >= 0 ? '+' : ''}{marketStats.priceChangePercent.toFixed(2)}%
|
||||
</span>
|
||||
|
||||
{/* Compact H/L */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-gray-500">
|
||||
<span>H <span className="text-gray-300">{marketStats.high.toFixed(2)}</span></span>
|
||||
<span>L <span className="text-gray-300">{marketStats.low.toFixed(2)}</span></span>
|
||||
{marketStats.volume > 0 && baseUnit && (
|
||||
<span>Vol <span className="text-gray-300">{formatVolume(marketStats.volume)}</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Controls */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{loading && (
|
||||
<span className="text-[10px] text-yellow-400 animate-pulse mr-2">
|
||||
{language === 'zh' ? '更新中...' : 'Updating...'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowIndicatorPanel(!showIndicatorPanel)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
background: showIndicatorPanel ? 'rgba(96, 165, 250, 0.15)' : 'transparent',
|
||||
color: showIndicatorPanel ? '#60A5FA' : '#6B7280',
|
||||
}}
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
<span>{language === 'zh' ? '指标' : 'Indicators'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowOrderMarkers(!showOrderMarkers)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
background: showOrderMarkers ? 'rgba(16, 185, 129, 0.15)' : 'transparent',
|
||||
color: showOrderMarkers ? '#10B981' : '#6B7280',
|
||||
}}
|
||||
title={language === 'zh' ? '订单标记' : 'Order Markers'}
|
||||
>
|
||||
<span>B/S</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 指标面板 - 专业化设计 */}
|
||||
@@ -895,6 +978,24 @@ export function AdvancedChart({
|
||||
}}>
|
||||
{tooltipData.close?.toFixed(2)}
|
||||
</span>
|
||||
|
||||
{tooltipData.volume > 0 && baseUnit && (
|
||||
<>
|
||||
<span style={{ color: '#848E9C' }}>V({baseUnit}):</span>
|
||||
<span style={{ color: '#3B82F6', fontWeight: '500' }}>
|
||||
{formatVolume(tooltipData.volume)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tooltipData.quoteVolume > 0 && quoteUnit && (
|
||||
<>
|
||||
<span style={{ color: '#848E9C' }}>V({quoteUnit}):</span>
|
||||
<span style={{ color: '#3B82F6', fontWeight: '500' }}>
|
||||
{formatVolume(tooltipData.quoteVolume)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { EquityChart } from './EquityChart'
|
||||
import { AdvancedChart } from './AdvancedChart'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { BarChart3, CandlestickChart } from 'lucide-react'
|
||||
import { BarChart3, CandlestickChart, ChevronDown, Search } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
interface ChartTabsProps {
|
||||
@@ -15,6 +15,22 @@ interface ChartTabsProps {
|
||||
|
||||
type ChartTab = 'equity' | 'kline'
|
||||
type Interval = '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d'
|
||||
type MarketType = 'hyperliquid' | 'crypto' | 'stocks' | 'forex' | 'metals'
|
||||
|
||||
interface SymbolInfo {
|
||||
symbol: string
|
||||
name: string
|
||||
category: string
|
||||
}
|
||||
|
||||
// 市场类型配置
|
||||
const MARKET_CONFIG = {
|
||||
hyperliquid: { exchange: 'hyperliquid', defaultSymbol: 'BTC', icon: '🔷', label: { zh: 'HL', en: 'HL' }, color: 'cyan', hasDropdown: true },
|
||||
crypto: { exchange: 'binance', defaultSymbol: 'BTCUSDT', icon: '₿', label: { zh: '加密', en: 'Crypto' }, color: 'yellow', hasDropdown: false },
|
||||
stocks: { exchange: 'alpaca', defaultSymbol: 'AAPL', icon: '📈', label: { zh: '美股', en: 'Stocks' }, color: 'green', hasDropdown: false },
|
||||
forex: { exchange: 'forex', defaultSymbol: 'EUR/USD', icon: '💱', label: { zh: '外汇', en: 'Forex' }, color: 'blue', hasDropdown: false },
|
||||
metals: { exchange: 'metals', defaultSymbol: 'XAU/USD', icon: '🥇', label: { zh: '金属', en: 'Metals' }, color: 'amber', hasDropdown: false },
|
||||
}
|
||||
|
||||
const INTERVALS: { value: Interval; label: string }[] = [
|
||||
{ value: '1m', label: '1m' },
|
||||
@@ -29,9 +45,63 @@ const INTERVALS: { value: Interval; label: string }[] = [
|
||||
export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: ChartTabsProps) {
|
||||
const { language } = useLanguage()
|
||||
const [activeTab, setActiveTab] = useState<ChartTab>('equity')
|
||||
const [chartSymbol, setChartSymbol] = useState<string>('BTCUSDT')
|
||||
const [chartSymbol, setChartSymbol] = useState<string>('BTC')
|
||||
const [interval, setInterval] = useState<Interval>('5m')
|
||||
const [symbolInput, setSymbolInput] = useState('')
|
||||
const [marketType, setMarketType] = useState<MarketType>('hyperliquid')
|
||||
const [availableSymbols, setAvailableSymbols] = useState<SymbolInfo[]>([])
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 根据市场类型确定交易所
|
||||
const marketConfig = MARKET_CONFIG[marketType]
|
||||
const currentExchange = marketType === 'crypto' ? (exchangeId || marketConfig.exchange) : marketConfig.exchange
|
||||
|
||||
// 获取可用币种列表
|
||||
useEffect(() => {
|
||||
if (marketConfig.hasDropdown) {
|
||||
fetch(`/api/symbols?exchange=${marketConfig.exchange}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.symbols) {
|
||||
// 按类别排序: crypto > stock > forex > commodity > index
|
||||
const categoryOrder: Record<string, number> = { crypto: 0, stock: 1, forex: 2, commodity: 3, index: 4 }
|
||||
const sorted = [...data.symbols].sort((a: SymbolInfo, b: SymbolInfo) => {
|
||||
const orderA = categoryOrder[a.category] ?? 5
|
||||
const orderB = categoryOrder[b.category] ?? 5
|
||||
if (orderA !== orderB) return orderA - orderB
|
||||
return a.symbol.localeCompare(b.symbol)
|
||||
})
|
||||
setAvailableSymbols(sorted)
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Failed to fetch symbols:', err))
|
||||
}
|
||||
}, [marketType, marketConfig.exchange, marketConfig.hasDropdown])
|
||||
|
||||
// 点击外部关闭下拉
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// 切换市场类型时更新默认符号
|
||||
const handleMarketTypeChange = (type: MarketType) => {
|
||||
setMarketType(type)
|
||||
setChartSymbol(MARKET_CONFIG[type].defaultSymbol)
|
||||
setShowDropdown(false)
|
||||
}
|
||||
|
||||
// 过滤后的币种列表
|
||||
const filteredSymbols = availableSymbols.filter(s =>
|
||||
s.symbol.toLowerCase().includes(searchFilter.toLowerCase())
|
||||
)
|
||||
|
||||
// 当从外部选择币种时,自动切换到K线图
|
||||
useEffect(() => {
|
||||
@@ -42,11 +112,15 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
}
|
||||
}, [selectedSymbol, updateKey])
|
||||
|
||||
// 处理手动输入币种
|
||||
// 处理手动输入符号
|
||||
const handleSymbolSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (symbolInput.trim()) {
|
||||
const symbol = symbolInput.trim().toUpperCase()
|
||||
let symbol = symbolInput.trim().toUpperCase()
|
||||
// 加密货币自动加 USDT 后缀
|
||||
if (marketType === 'crypto' && !symbol.endsWith('USDT')) {
|
||||
symbol = symbol + 'USDT'
|
||||
}
|
||||
setChartSymbol(symbol)
|
||||
setSymbolInput('')
|
||||
}
|
||||
@@ -55,65 +129,131 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
console.log('[ChartTabs] rendering, activeTab:', activeTab)
|
||||
|
||||
return (
|
||||
<div className="binance-card">
|
||||
{/* Tab Headers - 专业化工具栏 */}
|
||||
<div className="binance-card" style={{ background: '#0D1117', borderRadius: '8px', overflow: 'hidden' }}>
|
||||
{/* Clean Professional Toolbar */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2"
|
||||
style={{
|
||||
borderBottom: '1px solid #2B3139',
|
||||
background: 'linear-gradient(180deg, #1A1E23 0%, #0B0E11 100%)',
|
||||
}}
|
||||
className="flex items-center justify-between px-3 py-1.5"
|
||||
style={{ borderBottom: '1px solid rgba(43, 49, 57, 0.6)', background: '#161B22' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Left: Tab Switcher */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('[ChartTabs] switching to equity')
|
||||
setActiveTab('equity')
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'equity'
|
||||
? 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/40'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
|
||||
}`}
|
||||
onClick={() => setActiveTab('equity')}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[11px] font-medium transition-all ${
|
||||
activeTab === 'equity'
|
||||
? 'bg-blue-500/15 text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
<BarChart3 className="w-3 h-3" />
|
||||
<span>{t('accountEquityCurve', language)}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('[ChartTabs] switching to kline')
|
||||
setActiveTab('kline')
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'kline'
|
||||
? 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/40'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
|
||||
}`}
|
||||
onClick={() => setActiveTab('kline')}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[11px] font-medium transition-all ${
|
||||
activeTab === 'kline'
|
||||
? 'bg-blue-500/15 text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<CandlestickChart className="w-3.5 h-3.5" />
|
||||
<CandlestickChart className="w-3 h-3" />
|
||||
<span>{t('marketChart', language)}</span>
|
||||
</button>
|
||||
|
||||
{/* Market Type Pills - Only when kline active */}
|
||||
{activeTab === 'kline' && (
|
||||
<>
|
||||
<div className="w-px h-3 bg-[#30363D] mx-1" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
{(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => {
|
||||
const config = MARKET_CONFIG[type]
|
||||
const isActive = marketType === type
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleMarketTypeChange(type)}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-all ${
|
||||
isActive
|
||||
? 'bg-[#21262D] text-white'
|
||||
: 'text-gray-500 hover:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{config.icon} {language === 'zh' ? config.label.zh : config.label.en}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 币种选择器和时间周期选择器 - 仅在K线图模式下显示 */}
|
||||
{/* Right: Symbol + Interval */}
|
||||
{activeTab === 'kline' && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 当前币种显示 */}
|
||||
<div className="px-2.5 py-1 bg-[#1A1E23] border border-[#2B3139] rounded text-xs font-bold text-yellow-400">
|
||||
{chartSymbol}
|
||||
</div>
|
||||
{/* Symbol Dropdown */}
|
||||
{marketConfig.hasDropdown ? (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-[#21262D] rounded text-[11px] font-bold text-white hover:bg-[#30363D] transition-all"
|
||||
>
|
||||
<span>{chartSymbol}</span>
|
||||
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full right-0 mt-1 w-56 bg-[#161B22] border border-[#30363D] rounded-lg shadow-2xl z-50 max-h-72 overflow-hidden">
|
||||
<div className="p-2 border-b border-[#30363D]">
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-[#0D1117] rounded border border-[#30363D]">
|
||||
<Search className="w-3 h-3 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="flex-1 bg-transparent text-[11px] text-white placeholder-gray-600 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-52">
|
||||
{['crypto', 'stock', 'forex', 'commodity', 'index'].map(category => {
|
||||
const categorySymbols = filteredSymbols.filter(s => s.category === category)
|
||||
if (categorySymbols.length === 0) return null
|
||||
const labels: Record<string, string> = { crypto: 'Crypto', stock: 'Stocks', forex: 'Forex', commodity: 'Commodities', index: 'Index' }
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="px-3 py-1 text-[9px] font-medium text-gray-500 bg-[#0D1117] uppercase tracking-wider">{labels[category]}</div>
|
||||
{categorySymbols.map(s => (
|
||||
<button
|
||||
key={s.symbol}
|
||||
onClick={() => { setChartSymbol(s.symbol); setShowDropdown(false); setSearchFilter('') }}
|
||||
className={`w-full px-3 py-1.5 text-left text-[11px] hover:bg-[#21262D] transition-all ${chartSymbol === s.symbol ? 'bg-blue-500/20 text-blue-400' : 'text-gray-300'}`}
|
||||
>
|
||||
{s.symbol}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-[#21262D] rounded text-[11px] font-bold text-white">{chartSymbol}</span>
|
||||
)}
|
||||
|
||||
<div className="w-px h-4 bg-[#2B3139]"></div>
|
||||
|
||||
{/* 时间周期选择器 - 更紧凑专业 */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Interval Selector */}
|
||||
<div className="flex items-center bg-[#21262D] rounded overflow-hidden">
|
||||
{INTERVALS.map((int) => (
|
||||
<button
|
||||
key={int.value}
|
||||
onClick={() => setInterval(int.value)}
|
||||
className={`px-2 py-1 text-[10px] font-medium transition-all ${
|
||||
interval === int.value
|
||||
? 'bg-yellow-500/20 text-yellow-400 rounded'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
? 'bg-blue-500/30 text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-300 hover:bg-[#30363D]'
|
||||
}`}
|
||||
>
|
||||
{int.label}
|
||||
@@ -121,22 +261,17 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-[#2B3139]"></div>
|
||||
|
||||
{/* 币种输入框 - 更紧凑 */}
|
||||
<form onSubmit={handleSymbolSubmit} className="flex items-center gap-1.5">
|
||||
{/* Quick Input */}
|
||||
<form onSubmit={handleSymbolSubmit} className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={symbolInput}
|
||||
onChange={(e) => setSymbolInput(e.target.value)}
|
||||
placeholder="输入币种..."
|
||||
className="px-2 py-1 bg-[#1A1E23] border border-[#2B3139] rounded text-[11px] text-white placeholder-gray-600 focus:outline-none focus:border-yellow-500/50 w-24"
|
||||
placeholder="Symbol..."
|
||||
className="w-20 px-2 py-1 bg-[#0D1117] border border-[#30363D] rounded-l text-[10px] text-white placeholder-gray-600 focus:outline-none focus:border-blue-500/50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-2 py-1 bg-yellow-500/15 text-yellow-400 border border-yellow-500/30 rounded text-[10px] font-medium hover:bg-yellow-500/25 transition-all"
|
||||
>
|
||||
GO
|
||||
<button type="submit" className="px-2 py-1 bg-[#21262D] border border-[#30363D] border-l-0 rounded-r text-[10px] text-gray-400 hover:text-white hover:bg-[#30363D] transition-all">
|
||||
Go
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -159,7 +294,7 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={`kline-${chartSymbol}-${interval}-${exchangeId}`}
|
||||
key={`kline-${chartSymbol}-${interval}-${currentExchange}`}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
@@ -171,7 +306,7 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
interval={interval}
|
||||
traderID={traderId}
|
||||
height={550}
|
||||
exchange={exchangeId || 'binance'}
|
||||
exchange={currentExchange}
|
||||
onSymbolChange={setChartSymbol}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -315,9 +315,11 @@ function PositionRow({ position }: { position: HistoricalPosition }) {
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Fee */}
|
||||
{/* Fee - show more precision for small fees */}
|
||||
<td className="py-3 px-4 text-right font-mono text-xs" style={{ color: '#848E9C' }}>
|
||||
-{(position.fee || 0).toFixed(2)}
|
||||
-{((position.fee || 0) < 0.01 && (position.fee || 0) > 0)
|
||||
? (position.fee || 0).toFixed(4)
|
||||
: (position.fee || 0).toFixed(2)}
|
||||
</td>
|
||||
|
||||
{/* Duration */}
|
||||
|
||||
@@ -65,10 +65,39 @@ export function CoinSourceEditor({
|
||||
{ value: 'mixed', icon: Database, color: '#60a5fa' },
|
||||
] as const
|
||||
|
||||
// xyz dex assets (stocks, forex, commodities) - should NOT get USDT suffix
|
||||
const xyzDexAssets = new Set([
|
||||
// Stocks
|
||||
'TSLA', 'NVDA', 'AAPL', 'MSFT', 'META', 'AMZN', 'GOOGL', 'AMD', 'COIN', 'NFLX',
|
||||
'PLTR', 'HOOD', 'INTC', 'MSTR', 'TSM', 'ORCL', 'MU', 'RIVN', 'COST', 'LLY',
|
||||
'CRCL', 'SKHX', 'SNDK',
|
||||
// Forex
|
||||
'EUR', 'JPY',
|
||||
// Commodities
|
||||
'GOLD', 'SILVER',
|
||||
// Index
|
||||
'XYZ100',
|
||||
])
|
||||
|
||||
const isXyzDexAsset = (symbol: string): boolean => {
|
||||
const base = symbol.toUpperCase().replace(/^XYZ:/, '').replace(/USDT$|USD$|-USDC$/, '')
|
||||
return xyzDexAssets.has(base)
|
||||
}
|
||||
|
||||
const handleAddCoin = () => {
|
||||
if (!newCoin.trim()) return
|
||||
const symbol = newCoin.toUpperCase().trim()
|
||||
const formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
|
||||
|
||||
// For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT
|
||||
let formattedSymbol: string
|
||||
if (isXyzDexAsset(symbol)) {
|
||||
// Remove xyz: prefix (case-insensitive) and any USD suffixes
|
||||
const base = symbol.replace(/^xyz:/i, '').replace(/USDT$|USD$|-USDC$/i, '')
|
||||
formattedSymbol = `xyz:${base}`
|
||||
} else {
|
||||
formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
|
||||
}
|
||||
|
||||
const currentCoins = config.static_coins || []
|
||||
if (!currentCoins.includes(formattedSymbol)) {
|
||||
onChange({
|
||||
|
||||
Reference in New Issue
Block a user