Files
nofx/web/src/pages/TraderDashboardPage.tsx

889 lines
51 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useRef } from 'react'
import { mutate } from 'swr'
import { api } from '../lib/api'
import { ChartTabs } from '../components/charts/ChartTabs'
import { DecisionCard } from '../components/trader/DecisionCard'
import { PositionHistory } from '../components/trader/PositionHistory'
import { PunkAvatar, getTraderAvatar } from '../components/common/PunkAvatar'
import { confirmToast, notify } from '../lib/notify'
import { formatPrice, formatQuantity } from '../utils/format'
import { t, type Language } from '../i18n/translations'
import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react'
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
import { NofxSelect } from '../components/ui/select'
import { GridRiskPanel } from '../components/strategy/GridRiskPanel'
import type {
SystemStatus,
AccountInfo,
Position,
DecisionRecord,
Statistics,
TraderInfo,
Exchange,
} from '../types'
// --- Helper Functions ---
// Get friendly AI model display name
function getModelDisplayName(modelId: string): string {
switch (modelId.toLowerCase()) {
case 'deepseek':
return 'DeepSeek'
case 'qwen':
return 'Qwen'
case 'claude':
return 'Claude'
default:
return modelId.toUpperCase()
}
}
// Helper function to get exchange display name from exchange ID (UUID)
function getExchangeDisplayNameFromList(
exchangeId: string | undefined,
exchanges: Exchange[] | undefined
): string {
if (!exchangeId) return 'Unknown'
const exchange = exchanges?.find((e) => e.id === exchangeId)
if (!exchange) return exchangeId.substring(0, 8).toUpperCase() + '...'
const typeName = exchange.exchange_type?.toUpperCase() || exchange.name
return exchange.account_name
? `${typeName} - ${exchange.account_name}`
: typeName
}
// Helper function to get exchange type from exchange ID (UUID) - for kline charts
function getExchangeTypeFromList(
exchangeId: string | undefined,
exchanges: Exchange[] | undefined
): string {
if (!exchangeId) return 'binance'
const exchange = exchanges?.find((e) => e.id === exchangeId)
if (!exchange) return 'binance' // Default to binance for charts
return exchange.exchange_type?.toLowerCase() || 'binance'
}
// Helper function to check if exchange is a perp-dex type (wallet-based)
function isPerpDexExchange(exchangeType: string | undefined): boolean {
if (!exchangeType) return false
const perpDexTypes = ['hyperliquid', 'lighter', 'aster']
return perpDexTypes.includes(exchangeType.toLowerCase())
}
// Helper function to get wallet address for perp-dex exchanges
function getWalletAddress(exchange: Exchange | undefined): string | undefined {
if (!exchange) return undefined
const type = exchange.exchange_type?.toLowerCase()
switch (type) {
case 'hyperliquid':
return exchange.hyperliquidWalletAddr
case 'lighter':
return exchange.lighterWalletAddr
case 'aster':
return exchange.asterSigner
default:
return undefined
}
}
// Helper function to truncate wallet address for display
function truncateAddress(address: string, startLen = 6, endLen = 4): string {
if (address.length <= startLen + endLen + 3) return address
return `${address.slice(0, startLen)}...${address.slice(-endLen)}`
}
// --- Components ---
interface TraderDashboardPageProps {
selectedTrader?: TraderInfo
traders?: TraderInfo[]
tradersError?: Error
selectedTraderId?: string
onTraderSelect: (traderId: string) => void
onNavigateToTraders: () => void
status?: SystemStatus
account?: AccountInfo
accountFailed?: boolean
positions?: Position[]
positionsFailed?: boolean
decisions?: DecisionRecord[]
decisionsFailed?: boolean
decisionsLimit: number
onDecisionsLimitChange: (limit: number) => void
stats?: Statistics
lastUpdate: string
language: Language
exchanges?: Exchange[]
}
export function TraderDashboardPage({
selectedTrader,
status,
account,
accountFailed,
positions,
positionsFailed,
decisions,
decisionsFailed,
decisionsLimit,
onDecisionsLimitChange,
lastUpdate,
language,
traders,
tradersError,
selectedTraderId,
onTraderSelect,
onNavigateToTraders,
exchanges,
}: TraderDashboardPageProps) {
const [closingPosition, setClosingPosition] = useState<string | null>(null)
const [selectedChartSymbol, setSelectedChartSymbol] = useState<string | undefined>(undefined)
const [chartUpdateKey, setChartUpdateKey] = useState<number>(0)
const chartSectionRef = useRef<HTMLDivElement>(null)
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])
// Auto-set chart symbol for grid trading
useEffect(() => {
if (status?.strategy_type === 'grid_trading' && status?.grid_symbol) {
setSelectedChartSymbol(status.grid_symbol)
}
}, [status?.strategy_type, status?.grid_symbol])
// Get current exchange info for perp-dex wallet display
const currentExchange = exchanges?.find(
(e) => e.id === selectedTrader?.exchange_id
)
const walletAddress = getWalletAddress(currentExchange)
const isPerpDex = isPerpDexExchange(currentExchange?.exchange_type)
// Copy wallet address to clipboard
const handleCopyAddress = async () => {
if (!walletAddress) return
try {
await navigator.clipboard.writeText(walletAddress)
setCopiedAddress(true)
setTimeout(() => setCopiedAddress(false), 2000)
} catch (err) {
console.error('Failed to copy address:', err)
}
}
// Handle symbol click from Decision Card
const handleSymbolClick = (symbol: string) => {
// Set the selected symbol
setSelectedChartSymbol(symbol)
// Scroll to chart section
setTimeout(() => {
chartSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 100)
}
// Close position handler
const handleClosePosition = async (symbol: string, side: string) => {
if (!selectedTraderId) return
const sideLabel = side === 'LONG' ? 'LONG' : 'SHORT'
const confirmMsg = t('traderDashboard.confirmClosePosition', language, { symbol, side: sideLabel })
const confirmed = await confirmToast(confirmMsg, {
title: t('traderDashboard.confirmClose', language),
okText: t('traderDashboard.confirm', language),
cancelText: t('traderDashboard.cancel', language),
})
if (!confirmed) return
setClosingPosition(symbol)
try {
await api.closePosition(selectedTraderId, symbol, side)
notify.success(t('traderDashboard.positionClosed', language))
// Use SWR mutate to refresh data instead of reloading page
await Promise.all([
mutate(`positions-${selectedTraderId}`),
mutate(`account-${selectedTraderId}`),
])
} catch (err: unknown) {
const errorMsg =
err instanceof Error
? err.message
: t('traderDashboard.closeFailed', language)
notify.error(errorMsg)
} finally {
setClosingPosition(null)
}
}
// If API failed with error, show empty state (likely backend not running)
if (tradersError) {
return (
<div className="flex items-center justify-center min-h-[60vh] relative z-10">
<div className="text-center max-w-md mx-auto px-6">
<div
className="w-24 h-24 mx-auto mb-6 rounded-full flex items-center justify-center nofx-glass"
style={{
background: 'rgba(240, 185, 11, 0.1)',
borderColor: 'rgba(240, 185, 11, 0.3)',
}}
>
<svg
className="w-12 h-12 text-nofx-gold"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h2 className="text-2xl font-bold mb-3 text-nofx-text-main">
{t('traderDashboard.connectionFailed', language)}
</h2>
<p className="text-base mb-6 text-nofx-text-muted">
{t('traderDashboard.connectionFailedDesc', language)}
</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105 active:scale-95 nofx-glass border border-nofx-gold/30 text-nofx-gold hover:bg-nofx-gold/10"
>
{t('traderDashboard.retry', language)}
</button>
</div>
</div>
)
}
// If traders is loaded and empty, show empty state
if (traders && traders.length === 0) {
return (
<div className="flex items-center justify-center min-h-[60vh] relative z-10">
<div className="text-center max-w-md mx-auto px-6">
<div
className="w-24 h-24 mx-auto mb-6 rounded-full flex items-center justify-center nofx-glass"
style={{
background: 'rgba(240, 185, 11, 0.1)',
borderColor: 'rgba(240, 185, 11, 0.3)',
}}
>
<svg
className="w-12 h-12 text-nofx-gold"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<h2 className="text-2xl font-bold mb-3 text-nofx-text-main">
{t('dashboardEmptyTitle', language)}
</h2>
<p className="text-base mb-6 text-nofx-text-muted">
{t('dashboardEmptyDescription', language)}
</p>
<button
onClick={onNavigateToTraders}
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105 active:scale-95 nofx-glass border border-nofx-gold/30 text-nofx-gold hover:bg-nofx-gold/10"
>
{t('goToTradersPage', language)}
</button>
</div>
</div>
)
}
// If traders is still loading or selectedTrader is not ready, show skeleton
if (!selectedTrader) {
return (
<div className="space-y-6 relative z-10">
<div className="nofx-glass p-6 animate-pulse">
<div className="h-8 w-48 mb-3 bg-nofx-bg/50 rounded"></div>
<div className="flex gap-4">
<div className="h-4 w-32 bg-nofx-bg/50 rounded"></div>
<div className="h-4 w-24 bg-nofx-bg/50 rounded"></div>
<div className="h-4 w-28 bg-nofx-bg/50 rounded"></div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="nofx-glass p-5 animate-pulse">
<div className="h-4 w-24 mb-3 bg-nofx-bg/50 rounded"></div>
<div className="h-8 w-32 bg-nofx-bg/50 rounded"></div>
</div>
))}
</div>
<div className="nofx-glass p-6 animate-pulse">
<div className="h-6 w-40 mb-4 bg-nofx-bg/50 rounded"></div>
<div className="h-64 w-full bg-nofx-bg/50 rounded"></div>
</div>
</div>
)
}
return (
<DeepVoidBackground className="min-h-screen pb-12" disableAnimation>
<div className="w-full px-4 md:px-8 relative z-10 pt-6">
{/* Trader Header */}
<div
className="mb-6 rounded-lg p-6 animate-scale-in nofx-glass group"
style={{
background: 'linear-gradient(135deg, rgba(15, 23, 42, 0.6) 0%, rgba(15, 23, 42, 0.4) 100%)',
}}
>
<div className="flex items-start justify-between mb-4">
<h2 className="text-2xl font-bold flex items-center gap-4 text-nofx-text-main">
<div className="relative">
<PunkAvatar
seed={getTraderAvatar(
selectedTrader.trader_id,
selectedTrader.trader_name
)}
size={56}
className="rounded-xl border-2 border-nofx-gold/30 shadow-[0_0_15px_rgba(240,185,11,0.2)]"
/>
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-nofx-green rounded-full border-2 border-[#0B0E11] shadow-[0_0_8px_rgba(14,203,129,0.8)] animate-pulse" />
</div>
<div className="flex flex-col">
<span className="text-3xl tracking-tight text-nofx-text font-semibold">
{selectedTrader.trader_name}
</span>
<span className="text-xs font-mono text-nofx-text-muted opacity-60 flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-nofx-gold rounded-full" />
ID: {selectedTrader.trader_id.slice(0, 8)}...
</span>
</div>
</h2>
<div className="flex items-center gap-4">
{/* Trader Selector */}
{traders && traders.length > 0 && (
<div className="flex items-center gap-2 nofx-glass px-1 py-1 rounded-lg border border-white/5">
<NofxSelect
value={selectedTraderId || ''}
onChange={(val) => onTraderSelect(val)}
options={traders.map(t => ({ value: t.trader_id, label: t.trader_name }))}
className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main px-2 py-1"
/>
</div>
)}
{/* Wallet Address Display for Perp-DEX */}
{exchanges && isPerpDex && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg nofx-glass border border-nofx-gold/20">
{walletAddress ? (
<>
<span className="text-xs font-mono text-nofx-gold">
{showWalletAddress
? walletAddress
: truncateAddress(walletAddress)}
</span>
<button
type="button"
onClick={() => setShowWalletAddress(!showWalletAddress)}
className="p-1 rounded hover:bg-white/10 transition-colors"
title={
showWalletAddress
? t('traderDashboard.hideAddress', language)
: t('traderDashboard.showFullAddress', language)
}
>
{showWalletAddress ? (
<EyeOff className="w-3.5 h-3.5 text-nofx-text-muted" />
) : (
<Eye className="w-3.5 h-3.5 text-nofx-text-muted" />
)}
</button>
<button
type="button"
onClick={handleCopyAddress}
className="p-1 rounded hover:bg-white/10 transition-colors"
title={t('traderDashboard.copyAddress', language)}
>
{copiedAddress ? (
<Check className="w-3.5 h-3.5 text-nofx-green" />
) : (
<Copy className="w-3.5 h-3.5 text-nofx-text-muted" />
)}
</button>
</>
) : (
<span className="text-xs text-nofx-text-muted">
{t('traderDashboard.noAddressConfigured', language)}
</span>
)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-6 text-sm flex-wrap text-nofx-text-muted font-mono pl-2">
<span className="flex items-center gap-2">
<span className="opacity-60">AI Model:</span>
<span
className="font-bold px-2 py-0.5 rounded text-xs tracking-wide"
style={{
background: selectedTrader.ai_model.includes('qwen') ? 'rgba(192, 132, 252, 0.15)' : 'rgba(96, 165, 250, 0.15)',
color: selectedTrader.ai_model.includes('qwen') ? '#c084fc' : '#60a5fa',
border: `1px solid ${selectedTrader.ai_model.includes('qwen') ? '#c084fc' : '#60a5fa'}40`
}}
>
{getModelDisplayName(
selectedTrader.ai_model.split('_').pop() ||
selectedTrader.ai_model
)}
</span>
</span>
<span className="w-px h-3 bg-white/10 hidden md:block" />
<span className="flex items-center gap-2">
<span className="opacity-60">Exchange:</span>
<span className="text-nofx-text-main font-semibold">
{getExchangeDisplayNameFromList(
selectedTrader.exchange_id,
exchanges
)}
</span>
</span>
<span className="w-px h-3 bg-white/10 hidden md:block" />
<span className="flex items-center gap-2">
<span className="opacity-60">Strategy:</span>
<span className="text-nofx-gold font-semibold tracking-wide">
{selectedTrader.strategy_name || 'No Strategy'}
</span>
</span>
{status && (
<div className="hidden md:contents">
<span className="w-px h-3 bg-white/10" />
<span>Cycles: <span className="text-nofx-text-main">{status.call_count}</span></span>
<span className="w-px h-3 bg-white/10" />
<span>Runtime: <span className="text-nofx-text-main">{status.runtime_minutes} min</span></span>
</div>
)}
</div>
</div>
{/* Debug Info */}
<div className="mb-4 px-3 py-1.5 rounded bg-black/40 border border-white/5 text-[10px] font-mono text-nofx-text-muted flex justify-between items-center opacity-60 hover:opacity-100 transition-opacity">
<span style={{ color: '#0ECB81' }}>SYSTEM_STATUS::ONLINE</span>
{account ? (
<div className="flex gap-4">
<span>LAST_UPDATE::{lastUpdate}</span>
<span>EQ::{account.total_equity?.toFixed(2)}</span>
<span>PNL::{account.total_pnl?.toFixed(2)}</span>
</div>
) : accountFailed ? (
<span style={{ color: '#F6465D' }}>{t('traderDashboard.accountFetchFailed', language)}</span>
) : (
<div className="flex gap-4">
<span className="inline-block w-32 h-3 rounded bg-white/5 animate-pulse" />
<span className="inline-block w-16 h-3 rounded bg-white/5 animate-pulse" />
<span className="inline-block w-16 h-3 rounded bg-white/5 animate-pulse" />
</div>
)}
</div>
{/* Account Overview */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatCard
title={t('totalEquity', language)}
value={accountFailed && !account ? '--' : `${account?.total_equity?.toFixed(2) ?? '--'}`}
unit="USDT"
change={account ? (account.total_pnl_pct || 0) : undefined}
positive={(account?.total_pnl ?? 0) > 0}
icon="💰"
loading={!account && !accountFailed}
/>
<StatCard
title={t('availableBalance', language)}
value={accountFailed && !account ? '--' : `${account?.available_balance?.toFixed(2) ?? '--'}`}
unit="USDT"
subtitle={accountFailed && !account ? '--' : `${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '--'}% ${t('free', language)}`}
icon="💳"
loading={!account && !accountFailed}
/>
<StatCard
title={t('totalPnL', language)}
value={accountFailed && !account ? '--' : `${account?.total_pnl !== undefined && account.total_pnl >= 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) ?? '--'}`}
unit="USDT"
change={account ? (account.total_pnl_pct || 0) : undefined}
positive={(account?.total_pnl ?? 0) >= 0}
icon="📈"
loading={!account && !accountFailed}
/>
<StatCard
title={t('positions', language)}
value={accountFailed && !account ? '--' : `${account?.position_count ?? '--'}`}
unit="ACTIVE"
subtitle={accountFailed && !account ? `${t('margin', language)}: --` : `${t('margin', language)}: ${account?.margin_used_pct?.toFixed(1) ?? '--'}%`}
icon="📊"
loading={!account && !accountFailed}
/>
</div>
{/* Grid Risk Panel - Only show for grid trading strategy */}
{status?.strategy_type === 'grid_trading' && selectedTraderId && (
<div className="mb-8 animate-slide-in" style={{ animationDelay: '0.05s' }}>
<GridRiskPanel
traderId={selectedTraderId}
language={language}
refreshInterval={5000}
/>
</div>
)}
{/* Main Content Area */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Left Column: Charts + Positions */}
<div className="space-y-6">
{/* Chart Tabs (Equity / K-line) */}
<div
ref={chartSectionRef}
className="chart-container animate-slide-in scroll-mt-32 backdrop-blur-sm"
style={{ animationDelay: '0.1s' }}
>
<ChartTabs
traderId={selectedTrader.trader_id}
selectedSymbol={selectedChartSymbol}
updateKey={chartUpdateKey}
exchangeId={getExchangeTypeFromList(
selectedTrader.exchange_id,
exchanges
)}
/>
</div>
{/* Current Positions */}
<div
className="nofx-glass p-6 animate-slide-in relative overflow-hidden group"
style={{ animationDelay: '0.15s' }}
>
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
<div className="w-24 h-24 rounded-full bg-blue-500 blur-3xl" />
</div>
<div className="flex items-center justify-between mb-5 relative z-10">
<h2 className="text-lg font-bold flex items-center gap-2 text-nofx-text-main uppercase tracking-wide">
<span className="text-blue-500"></span> {t('currentPositions', language)}
</h2>
{positions && positions.length > 0 && (
<div className="text-xs px-2 py-1 rounded bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 font-mono shadow-[0_0_10px_rgba(240,185,11,0.1)]">
{positions.length} {t('active', language)}
</div>
)}
</div>
{positions && positions.length > 0 ? (
<div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="text-left border-b border-white/5">
<tr>
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-left">{t('symbol', language)}</th>
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{t('side', language)}</th>
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{t('traderDashboard.action', language)}</th>
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('entryPrice', language)}>{t('traderDashboard.entry', language)}</th>
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('markPrice', language)}>{t('traderDashboard.mark', language)}</th>
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('quantity', language)}>{t('traderDashboard.qty', language)}</th>
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('positionValue', language)}>{t('traderDashboard.value', language)}</th>
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center hidden md:table-cell" title={t('leverage', language)}>{t('traderDashboard.lev', language)}</th>
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('unrealizedPnL', language)}>{t('traderDashboard.uPnL', language)}</th>
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('liqPrice', language)}>{t('traderDashboard.liq', language)}</th>
</tr>
</thead>
<tbody>
{paginatedPositions.map((pos, i) => (
<tr
key={i}
className="border-b border-white/5 last:border-0 transition-all hover:bg-white/5 cursor-pointer group/row"
onClick={() => {
setSelectedChartSymbol(pos.symbol)
setChartUpdateKey(Date.now())
if (chartSectionRef.current) {
chartSectionRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
}}
>
<td className="px-1 py-3 font-mono font-semibold whitespace-nowrap text-left text-nofx-text-main group-hover/row:text-white transition-colors">
{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 uppercase tracking-wider ${pos.side === 'long' ? 'bg-nofx-green/10 text-nofx-green shadow-[0_0_8px_rgba(14,203,129,0.2)]' : 'bg-nofx-red/10 text-nofx-red shadow-[0_0_8px_rgba(246,70,93,0.2)]'}`}
>
{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()
handleClosePosition(pos.symbol, pos.side.toUpperCase())
}}
disabled={closingPosition === pos.symbol}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[10px] font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed mx-auto bg-nofx-red/10 text-nofx-red border border-nofx-red/30 hover:bg-nofx-red/20"
title={t('traderDashboard.closePosition', language)}
>
{closingPosition === pos.symbol ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LogOut className="w-3 h-3" />
)}
{t('traderDashboard.close', language)}
</button>
</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{formatPrice(pos.entry_price)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{formatPrice(pos.mark_price)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{formatQuantity(pos.quantity)}</td>
<td className="px-1 py-3 font-mono font-bold whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{(pos.quantity * pos.mark_price).toFixed(2)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-center text-nofx-gold hidden md:table-cell">{pos.leverage}x</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right">
<span
className={`font-bold ${pos.unrealized_pnl >= 0 ? 'text-nofx-green shadow-nofx-green' : 'text-nofx-red shadow-nofx-red'}`}
style={{ textShadow: pos.unrealized_pnl >= 0 ? '0 0 10px rgba(14,203,129,0.3)' : '0 0 10px rgba(246,70,93,0.3)' }}
>
{pos.unrealized_pnl >= 0 ? '+' : ''}
{pos.unrealized_pnl.toFixed(2)}
</span>
</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-muted hidden md:table-cell">{formatPrice(pos.liquidation_price)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination footer */}
{totalPositions > 10 && (
<div className="flex flex-wrap items-center justify-between gap-3 pt-4 mt-4 text-xs border-t border-white/5 text-nofx-text-muted">
<span>
{t('traderDashboard.showingPositions', language, { shown: paginatedPositions.length, total: totalPositions })}
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span>{t('traderDashboard.perPage', language)}:</span>
<NofxSelect
value={positionsPageSize}
onChange={(val) => setPositionsPageSize(Number(val))}
options={[{ value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]}
className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main transition-colors"
/>
</div>
{totalPositionPages > 1 && (
<div className="flex items-center gap-1">
{['«', '', `${positionsCurrentPage} / ${totalPositionPages}`, '', '»'].map((label, idx) => {
const isText = idx === 2;
const isFirst = idx === 0;
const isPrev = idx === 1;
const isNext = idx === 3;
const isLast = idx === 4;
if (isText) return <span key={idx} className="px-3 text-nofx-text-main">{label}</span>;
let onClick = () => { };
let disabled = false;
if (isFirst) { onClick = () => setPositionsCurrentPage(1); disabled = positionsCurrentPage === 1; }
if (isPrev) { onClick = () => setPositionsCurrentPage(p => Math.max(1, p - 1)); disabled = positionsCurrentPage === 1; }
if (isNext) { onClick = () => setPositionsCurrentPage(p => Math.min(totalPositionPages, p + 1)); disabled = positionsCurrentPage === totalPositionPages; }
if (isLast) { onClick = () => setPositionsCurrentPage(totalPositionPages); disabled = positionsCurrentPage === totalPositionPages; }
return (
<button
key={idx}
onClick={onClick}
disabled={disabled}
className={`px-2 py-1 rounded transition-colors ${disabled ? 'opacity-30 cursor-not-allowed' : 'hover:bg-white/10 text-nofx-text-main bg-white/5'}`}
>
{label}
</button>
)
})}
</div>
)}
</div>
</div>
)}
</div>
) : positionsFailed ? (
<div className="text-center py-16 text-nofx-text-muted opacity-60">
<div className="text-4xl mb-4"></div>
<div className="text-lg font-semibold mb-2">{t('traderDashboard.positionsFetchFailed', language)}</div>
</div>
) : (
<div className="text-center py-16 text-nofx-text-muted opacity-60">
<div className="text-6xl mb-4 opacity-50 grayscale">📊</div>
<div className="text-lg font-semibold mb-2">{t('noPositions', language)}</div>
<div className="text-sm">{t('noActivePositions', language)}</div>
</div>
)}
</div>
</div>
{/* Right Column: Recent Decisions */}
<div
className="nofx-glass p-6 animate-slide-in h-fit lg:sticky lg:top-24 lg:max-h-[calc(100vh-120px)] flex flex-col"
style={{ animationDelay: '0.2s' }}
>
{/* Header */}
<div className="flex items-center gap-3 mb-5 pb-4 border-b border-white/5 shrink-0">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl shadow-[0_4px_14px_rgba(99,102,241,0.4)]"
style={{
background: 'linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%)',
}}
>
🧠
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-nofx-text-main">
{t('recentDecisions', language)}
</h2>
{decisions && decisions.length > 0 && (
<div className="text-xs text-nofx-text-muted">
{t('lastCycles', language, { count: decisions.length })}
</div>
)}
</div>
{/* Limit Selector */}
<NofxSelect
value={decisionsLimit}
onChange={(val) => onDecisionsLimitChange(Number(val))}
options={[{ value: 5, label: '5' }, { value: 10, label: '10' }, { value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]}
className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent"
/>
</div>
{/* Decisions List - Scrollable */}
<div
className="space-y-4 overflow-y-auto pr-2 custom-scrollbar"
style={{ maxHeight: 'calc(100vh - 280px)' }}
>
{decisions && decisions.length > 0 ? (
decisions.map((decision, i) => (
<DecisionCard key={i} decision={decision} language={language} onSymbolClick={handleSymbolClick} />
))
) : decisionsFailed ? (
<div className="py-16 text-center text-nofx-text-muted opacity-60">
<div className="text-4xl mb-4"></div>
<div className="text-lg font-semibold mb-2">{t('traderDashboard.decisionsFetchFailed', language)}</div>
</div>
) : (
<div className="py-16 text-center text-nofx-text-muted opacity-60">
<div className="text-6xl mb-4 opacity-30 grayscale">🧠</div>
<div className="text-lg font-semibold mb-2 text-nofx-text-main">
{t('noDecisionsYet', language)}
</div>
<div className="text-sm">
{t('aiDecisionsWillAppear', language)}
</div>
</div>
)}
</div>
</div>
</div>
{/* Position History Section */}
{selectedTraderId && (
<div
className="nofx-glass p-6 animate-slide-in"
style={{ animationDelay: '0.25s' }}
>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-bold flex items-center gap-2 text-nofx-text-main">
<span className="text-2xl">📜</span>
{t('positionHistory.title', language)}
</h2>
</div>
<PositionHistory traderId={selectedTraderId} />
</div>
)}
</div>
</DeepVoidBackground>
)
}
// Stat Card Component - Deep Void Style
function StatCard({
title,
value,
unit,
change,
positive,
subtitle,
icon,
loading,
}: {
title: string
value: string
unit?: string
change?: number
positive?: boolean
subtitle?: string
icon?: string
loading?: boolean
}) {
return (
<div className="group nofx-glass p-5 rounded-lg transition-all duration-300 hover:bg-white/5 hover:translate-y-[-2px] border border-white/5 hover:border-nofx-gold/20 relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity text-4xl grayscale group-hover:grayscale-0">
{icon}
</div>
<div className="text-xs mb-2 font-mono uppercase tracking-wider text-nofx-text-muted flex items-center gap-2">
{title}
</div>
{loading ? (
<div className="space-y-2">
<div className="h-7 w-24 rounded bg-white/5 animate-pulse" />
<div className="h-3 w-16 rounded bg-white/5 animate-pulse" />
</div>
) : (
<>
<div className="flex items-baseline gap-1 mb-1">
<div className="text-2xl font-bold font-mono text-nofx-text-main tracking-tight group-hover:text-white transition-colors">
{value}
</div>
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
</div>
{change !== undefined && (
<div className="flex items-center gap-1">
<div
className={`text-sm mono font-bold flex items-center gap-1 ${positive ? 'text-nofx-green' : 'text-nofx-red'}`}
>
<span>{positive ? '▲' : '▼'}</span>
<span>{positive ? '+' : ''}{change.toFixed(2)}%</span>
</div>
</div>
)}
{subtitle && (
<div className="text-xs mt-2 mono text-nofx-text-muted opacity-80">
{subtitle}
</div>
)}
</>
)}
</div>
)
}