feat: improve order sync and add xyz dex trigger orders

- Add incremental sync for Binance trades using COMMISSION detection and fromId
- Add stop loss and take profit order support for xyz dex assets
- Add pagination for current positions and position history in UI
- Fix chart market type auto-selection based on exchange
This commit is contained in:
tinkle-community
2025-12-30 14:32:51 +08:00
parent 0408bf1f5f
commit ad04994d75
7 changed files with 983 additions and 283 deletions

View File

@@ -799,6 +799,23 @@ function TraderDetailsPage({
const [showWalletAddress, setShowWalletAddress] = useState<boolean>(false)
const [copiedAddress, setCopiedAddress] = useState<boolean>(false)
// Current positions pagination
const [positionsPageSize, setPositionsPageSize] = useState<number>(20)
const [positionsCurrentPage, setPositionsCurrentPage] = useState<number>(1)
// Calculate paginated positions
const totalPositions = positions?.length || 0
const totalPositionPages = Math.ceil(totalPositions / positionsPageSize)
const paginatedPositions = positions?.slice(
(positionsCurrentPage - 1) * positionsPageSize,
positionsCurrentPage * positionsPageSize
) || []
// Reset page when positions change
useEffect(() => {
setPositionsCurrentPage(1)
}, [selectedTraderId, positionsPageSize])
// Get current exchange info for perp-dex wallet display
const currentExchange = exchanges?.find(
(e) => e.id === selectedTrader?.exchange_id
@@ -1250,180 +1267,269 @@ function TraderDetailsPage({
)}
</div>
{positions && positions.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="text-left border-b border-gray-800">
<tr>
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-left">
{t('symbol', language)}
</th>
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center">
{t('side', language)}
</th>
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center">
{language === 'zh' ? '操作' : 'Action'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('entryPrice', language)}
>
{language === 'zh' ? '入场价' : 'Entry'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('markPrice', language)}
>
{language === 'zh' ? '标记价' : 'Mark'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('quantity', language)}
>
{language === 'zh' ? '数量' : 'Qty'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('positionValue', language)}
>
{language === 'zh' ? '价值' : 'Value'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center"
title={t('leverage', language)}
>
{language === 'zh' ? '杠杆' : 'Lev.'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('unrealizedPnL', language)}
>
{language === 'zh' ? '未实现盈亏' : 'uPnL'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('liqPrice', language)}
>
{language === 'zh' ? '强平价' : 'Liq.'}
</th>
</tr>
</thead>
<tbody>
{positions.map((pos, i) => (
<tr
key={i}
className="border-b border-gray-800 last:border-0 transition-colors hover:bg-opacity-10 hover:bg-yellow-500 cursor-pointer"
onClick={() => {
setSelectedChartSymbol(pos.symbol)
setChartUpdateKey(Date.now())
// Smooth scroll to chart with ref
if (chartSectionRef.current) {
chartSectionRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
}}
>
<td className="px-1 py-3 font-mono font-semibold whitespace-nowrap text-left">
{pos.symbol}
</td>
<td className="px-1 py-3 whitespace-nowrap text-center">
<span
className="px-1.5 py-0.5 rounded text-[10px] font-bold"
style={
pos.side === 'long'
? {
background: 'rgba(14, 203, 129, 0.1)',
color: '#0ECB81',
}
: {
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
}
}
>
{t(
pos.side === 'long' ? 'long' : 'short',
language
)}
</span>
</td>
<td className="px-1 py-3 whitespace-nowrap text-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation() // Prevent row click
handleClosePosition(
pos.symbol,
pos.side.toUpperCase()
)
}}
disabled={closingPosition === pos.symbol}
className="btn-danger inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed mx-auto"
title={
language === 'zh' ? '平仓' : 'Close Position'
}
>
{closingPosition === pos.symbol ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LogOut className="w-3 h-3" />
)}
{language === 'zh' ? '平仓' : 'Close'}
</button>
</td>
<td
className="px-1 py-3 font-mono whitespace-nowrap text-right"
style={{ color: '#EAECEF' }}
<div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="text-left border-b border-gray-800">
<tr>
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-left">
{t('symbol', language)}
</th>
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center">
{t('side', language)}
</th>
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center">
{language === 'zh' ? '操作' : 'Action'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('entryPrice', language)}
>
{pos.entry_price.toFixed(4)}
</td>
<td
className="px-1 py-3 font-mono whitespace-nowrap text-right"
style={{ color: '#EAECEF' }}
{language === 'zh' ? '入场价' : 'Entry'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('markPrice', language)}
>
{pos.mark_price.toFixed(4)}
</td>
<td
className="px-1 py-3 font-mono whitespace-nowrap text-right"
style={{ color: '#EAECEF' }}
{language === 'zh' ? '标记价' : 'Mark'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('quantity', language)}
>
{pos.quantity.toFixed(4)}
</td>
<td
className="px-1 py-3 font-mono font-bold whitespace-nowrap text-right"
style={{ color: '#EAECEF' }}
{language === 'zh' ? '数量' : 'Qty'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('positionValue', language)}
>
{(pos.quantity * pos.mark_price).toFixed(2)}
</td>
<td
className="px-1 py-3 font-mono whitespace-nowrap text-center"
style={{ color: '#F0B90B' }}
{language === 'zh' ? '价值' : 'Value'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center"
title={t('leverage', language)}
>
{pos.leverage}x
</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right">
<span
style={{
color:
pos.unrealized_pnl >= 0 ? '#0ECB81' : '#F6465D',
fontWeight: 'bold',
}}
>
{pos.unrealized_pnl >= 0 ? '+' : ''}
{pos.unrealized_pnl.toFixed(2)}
</span>
</td>
<td
className="px-1 py-3 font-mono whitespace-nowrap text-right"
style={{ color: '#848E9C' }}
{language === 'zh' ? '杠杆' : 'Lev.'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('unrealizedPnL', language)}
>
{pos.liquidation_price.toFixed(4)}
</td>
{language === 'zh' ? '未实现盈亏' : 'uPnL'}
</th>
<th
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
title={t('liqPrice', language)}
>
{language === 'zh' ? '强平价' : 'Liq.'}
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{paginatedPositions.map((pos, i) => (
<tr
key={i}
className="border-b border-gray-800 last:border-0 transition-colors hover:bg-opacity-10 hover:bg-yellow-500 cursor-pointer"
onClick={() => {
setSelectedChartSymbol(pos.symbol)
setChartUpdateKey(Date.now())
// Smooth scroll to chart with ref
if (chartSectionRef.current) {
chartSectionRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
}}
>
<td className="px-1 py-3 font-mono font-semibold whitespace-nowrap text-left">
{pos.symbol}
</td>
<td className="px-1 py-3 whitespace-nowrap text-center">
<span
className="px-1.5 py-0.5 rounded text-[10px] font-bold"
style={
pos.side === 'long'
? {
background: 'rgba(14, 203, 129, 0.1)',
color: '#0ECB81',
}
: {
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
}
}
>
{t(
pos.side === 'long' ? 'long' : 'short',
language
)}
</span>
</td>
<td className="px-1 py-3 whitespace-nowrap text-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation() // Prevent row click
handleClosePosition(
pos.symbol,
pos.side.toUpperCase()
)
}}
disabled={closingPosition === pos.symbol}
className="btn-danger inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed mx-auto"
title={
language === 'zh' ? '平仓' : 'Close Position'
}
>
{closingPosition === pos.symbol ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LogOut className="w-3 h-3" />
)}
{language === 'zh' ? '平仓' : 'Close'}
</button>
</td>
<td
className="px-1 py-3 font-mono whitespace-nowrap text-right"
style={{ color: '#EAECEF' }}
>
{pos.entry_price.toFixed(4)}
</td>
<td
className="px-1 py-3 font-mono whitespace-nowrap text-right"
style={{ color: '#EAECEF' }}
>
{pos.mark_price.toFixed(4)}
</td>
<td
className="px-1 py-3 font-mono whitespace-nowrap text-right"
style={{ color: '#EAECEF' }}
>
{pos.quantity.toFixed(4)}
</td>
<td
className="px-1 py-3 font-mono font-bold whitespace-nowrap text-right"
style={{ color: '#EAECEF' }}
>
{(pos.quantity * pos.mark_price).toFixed(2)}
</td>
<td
className="px-1 py-3 font-mono whitespace-nowrap text-center"
style={{ color: '#F0B90B' }}
>
{pos.leverage}x
</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right">
<span
style={{
color:
pos.unrealized_pnl >= 0 ? '#0ECB81' : '#F6465D',
fontWeight: 'bold',
}}
>
{pos.unrealized_pnl >= 0 ? '+' : ''}
{pos.unrealized_pnl.toFixed(2)}
</span>
</td>
<td
className="px-1 py-3 font-mono whitespace-nowrap text-right"
style={{ color: '#848E9C' }}
>
{pos.liquidation_price.toFixed(4)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination footer - only show when there are many positions */}
{totalPositions > 10 && (
<div
className="flex flex-wrap items-center justify-between gap-3 pt-4 mt-4 text-xs"
style={{ borderTop: '1px solid #2B3139', color: '#848E9C' }}
>
<span>
{language === 'zh'
? `显示 ${paginatedPositions.length} / ${totalPositions} 个持仓`
: `Showing ${paginatedPositions.length} of ${totalPositions} positions`}
</span>
<div className="flex items-center gap-3">
{/* Page size selector */}
<div className="flex items-center gap-2">
<span style={{ color: '#848E9C' }}>
{language === 'zh' ? '每页' : 'Per page'}:
</span>
<select
value={positionsPageSize}
onChange={(e) => setPositionsPageSize(Number(e.target.value))}
className="rounded px-2 py-1 text-xs"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
{/* Page navigation */}
{totalPositionPages > 1 && (
<div className="flex items-center gap-1">
<button
onClick={() => setPositionsCurrentPage(1)}
disabled={positionsCurrentPage === 1}
className="px-2 py-1 rounded transition-colors disabled:opacity-30"
style={{
background: positionsCurrentPage === 1 ? 'transparent' : '#2B3139',
color: '#EAECEF',
}}
>
«
</button>
<button
onClick={() => setPositionsCurrentPage((p) => Math.max(1, p - 1))}
disabled={positionsCurrentPage === 1}
className="px-2 py-1 rounded transition-colors disabled:opacity-30"
style={{
background: positionsCurrentPage === 1 ? 'transparent' : '#2B3139',
color: '#EAECEF',
}}
>
</button>
<span className="px-3" style={{ color: '#EAECEF' }}>
{positionsCurrentPage} / {totalPositionPages}
</span>
<button
onClick={() => setPositionsCurrentPage((p) => Math.min(totalPositionPages, p + 1))}
disabled={positionsCurrentPage === totalPositionPages}
className="px-2 py-1 rounded transition-colors disabled:opacity-30"
style={{
background: positionsCurrentPage === totalPositionPages ? 'transparent' : '#2B3139',
color: '#EAECEF',
}}
>
</button>
<button
onClick={() => setPositionsCurrentPage(totalPositionPages)}
disabled={positionsCurrentPage === totalPositionPages}
className="px-2 py-1 rounded transition-colors disabled:opacity-30"
style={{
background: positionsCurrentPage === totalPositionPages ? 'transparent' : '#2B3139',
color: '#EAECEF',
}}
>
»
</button>
</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="text-center py-16" style={{ color: '#848E9C' }}>

View File

@@ -42,21 +42,37 @@ const INTERVALS: { value: Interval; label: string }[] = [
{ value: '1d', label: '1d' },
]
// 根据交易所ID推断市场类型
function getMarketTypeFromExchange(exchangeId: string | undefined): MarketType {
if (!exchangeId) return 'hyperliquid'
const lower = exchangeId.toLowerCase()
if (lower.includes('hyperliquid')) return 'hyperliquid'
// 其他交易所默认使用 crypto 类型
return 'crypto'
}
export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: ChartTabsProps) {
const { language } = useLanguage()
const [activeTab, setActiveTab] = useState<ChartTab>('equity')
const [chartSymbol, setChartSymbol] = useState<string>('BTC')
const [interval, setInterval] = useState<Interval>('5m')
const [symbolInput, setSymbolInput] = useState('')
const [marketType, setMarketType] = useState<MarketType>('hyperliquid')
const [marketType, setMarketType] = useState<MarketType>(() => getMarketTypeFromExchange(exchangeId))
const [availableSymbols, setAvailableSymbols] = useState<SymbolInfo[]>([])
const [showDropdown, setShowDropdown] = useState(false)
const [searchFilter, setSearchFilter] = useState('')
const dropdownRef = useRef<HTMLDivElement>(null)
// 当交易所ID变化时自动切换市场类型
useEffect(() => {
const newMarketType = getMarketTypeFromExchange(exchangeId)
setMarketType(newMarketType)
}, [exchangeId])
// 根据市场类型确定交易所
const marketConfig = MARKET_CONFIG[marketType]
const currentExchange = marketType === 'crypto' ? (exchangeId || marketConfig.exchange) : marketConfig.exchange
// 优先使用传入的 exchangeId非 hyperliquid 时)
const currentExchange = marketType === 'hyperliquid' ? 'hyperliquid' : (exchangeId || marketConfig.exchange)
// 获取可用币种列表
useEffect(() => {

View File

@@ -344,6 +344,10 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
const [symbolStats, setSymbolStats] = useState<SymbolStats[]>([])
const [directionStats, setDirectionStats] = useState<DirectionStats[]>([])
// Pagination state
const [pageSize, setPageSize] = useState<number>(20)
const [currentPage, setCurrentPage] = useState<number>(1)
// Filter state
const [filterSymbol, setFilterSymbol] = useState<string>('all')
const [filterSide, setFilterSide] = useState<string>('all')
@@ -355,7 +359,8 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
try {
setLoading(true)
setError(null)
const data = await api.getPositionHistory(traderId, 200)
// Fetch more data than needed to support filtering, but respect pageSize for initial load
const data = await api.getPositionHistory(traderId, Math.max(200, pageSize * 5))
setPositions(data.positions || [])
setStats(data.stats)
setSymbolStats(data.symbol_stats || [])
@@ -370,7 +375,7 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
if (traderId) {
fetchData()
}
}, [traderId])
}, [traderId, pageSize])
// Get unique symbols for filter
const uniqueSymbols = useMemo(() => {
@@ -378,8 +383,8 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
return Array.from(symbols).sort()
}, [positions])
// Filtered and sorted positions
const filteredPositions = useMemo(() => {
// Filtered and sorted positions (before pagination)
const filteredAndSortedPositions = useMemo(() => {
let result = [...positions]
// Apply filters
@@ -418,6 +423,24 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
return result
}, [positions, filterSymbol, filterSide, sortBy, sortOrder])
// Pagination calculations
const totalFilteredCount = filteredAndSortedPositions.length
const totalPages = Math.ceil(totalFilteredCount / pageSize)
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1)
}, [filterSymbol, filterSide, sortBy, sortOrder, pageSize])
// Paginated positions (for display)
const paginatedPositions = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize
return filteredAndSortedPositions.slice(startIndex, startIndex + pageSize)
}, [filteredAndSortedPositions, currentPage, pageSize])
// For backwards compatibility, keep filteredPositions as the paginated result
const filteredPositions = paginatedPositions
// Calculate profit/loss ratio (avg win / avg loss)
const profitLossRatio = useMemo(() => {
if (!stats) return 0
@@ -775,34 +798,114 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
</table>
</div>
{/* Footer */}
{/* Footer with Pagination */}
<div
className="flex items-center justify-between p-4 text-sm"
className="flex flex-wrap items-center justify-between gap-4 p-4 text-sm"
style={{ borderTop: '1px solid #2B3139', color: '#848E9C' }}
>
<span>
{t('positionHistory.showingPositions', language, { count: filteredPositions.length, total: positions.length })}
</span>
{filteredPositions.length > 0 && (
{/* Left: Count info */}
<div className="flex items-center gap-4">
<span>
{t('positionHistory.totalPnL', language)}:{' '}
<span
{t('positionHistory.showingPositions', language, { count: totalFilteredCount, total: positions.length })}
</span>
{totalFilteredCount > 0 && (
<span>
{t('positionHistory.totalPnL', language)}:{' '}
<span
style={{
color:
filteredAndSortedPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) >= 0
? '#0ECB81'
: '#F6465D',
}}
>
{filteredAndSortedPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) >= 0
? '+'
: ''}
{formatNumber(
filteredAndSortedPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0)
)}
</span>
</span>
)}
</div>
{/* Right: Pagination controls */}
<div className="flex items-center gap-3">
{/* Page size selector */}
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '每页' : 'Per page'}:
</span>
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="rounded px-2 py-1 text-sm"
style={{
color:
filteredPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) >= 0
? '#0ECB81'
: '#F6465D',
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
>
{filteredPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) >= 0
? '+'
: ''}
{formatNumber(
filteredPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0)
)}
</span>
</span>
)}
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
{/* Page navigation */}
{totalPages > 1 && (
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="px-2 py-1 rounded text-xs transition-colors disabled:opacity-30"
style={{
background: currentPage === 1 ? 'transparent' : '#2B3139',
color: '#EAECEF',
}}
>
«
</button>
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-2 py-1 rounded text-xs transition-colors disabled:opacity-30"
style={{
background: currentPage === 1 ? 'transparent' : '#2B3139',
color: '#EAECEF',
}}
>
</button>
<span className="px-3 text-xs" style={{ color: '#EAECEF' }}>
{currentPage} / {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-2 py-1 rounded text-xs transition-colors disabled:opacity-30"
style={{
background: currentPage === totalPages ? 'transparent' : '#2B3139',
color: '#EAECEF',
}}
>
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="px-2 py-1 rounded text-xs transition-colors disabled:opacity-30"
style={{
background: currentPage === totalPages ? 'transparent' : '#2B3139',
color: '#EAECEF',
}}
>
»
</button>
</div>
)}
</div>
</div>
</div>
</div>