'use client' import { useState, useMemo } from 'react' import { useLocale } from 'next-intl' import type { BotTrade, TrumpPost } from '@/types' import Pagination from '@/components/ui/Pagination' // ── Formatters ──────────────────────────────────────────────────────────────── function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) { const { decimals = 2, sign = false } = opts if (n == null || isNaN(n)) return '—' const abs = Math.abs(n) const s = abs.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) if (n < 0) return '-$' + s if (sign && n > 0) return '+$' + s return '$' + s } function fmtPct(n: number) { return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' } function fmtHold(s: number) { if (s < 60) return s + 's' const m = Math.floor(s / 60) if (m < 60) return m + 'm' return Math.floor(m / 60) + 'h ' + (m % 60) + 'm' } interface Props { trades: BotTrade[] posts: TrumpPost[] loading: boolean } // ASSETS filter is derived dynamically from the trade set (see useMemo below) // so new assets (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear automatically. const SIDES = ['all', 'long', 'short'] as const const TRADES_PER_PAGE = 25 export default function TradeTable({ trades, posts, loading }: Props) { const locale = useLocale() const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json const [assetFilter, setAssetFilter] = useState('all') const [sideFilter, setSideFilter] = useState('all') const [sourceFilter, setSourceFilter] = useState('all') const [hidePaper, setHidePaper] = useState(false) const [page, setPage] = useState(1) // Distinct assets present in the loaded trade set — drives the asset filter. // Dynamic so new perps (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear without // a code change; sorted alphabetically with BTC/ETH first for familiarity. const assets: string[] = useMemo(() => { const set = new Set() for (const t of trades) if (t.asset) set.add(t.asset) const priority = ['BTC', 'ETH', 'SOL', 'TRUMP'] const rest = Array.from(set).filter(a => !priority.includes(a)).sort() return ['all', ...priority.filter(a => set.has(a)), ...rest] }, [trades]) // Distinct sources present in the loaded trade set — drives the filter UI. // Includes 'unknown' as a bucket for trades whose trigger post was deleted. const sources = useMemo(() => { const set = new Set() for (const t of trades) set.add(t.trigger_source || 'unknown') return Array.from(set).sort() }, [trades]) // Per-source PnL aggregate — the critical view for "which module makes money". // Computed BEFORE the asset/side filter so the source breakdown reflects the // full universe, not whatever sub-filter is currently applied. const perSource = useMemo(() => { const acc: Record = {} for (const t of trades) { const k = t.trigger_source || 'unknown' if (!acc[k]) acc[k] = { trades: 0, pnl: 0, wins: 0, paper: 0 } acc[k].trades += 1 if (t.pnl_usd != null) { acc[k].pnl += t.pnl_usd if (t.pnl_usd > 0) acc[k].wins += 1 } if (t.is_paper) acc[k].paper += 1 } return acc }, [trades]) const filtered = useMemo(() => trades.filter(t => { if (sourceFilter !== 'all' && (t.trigger_source || 'unknown') !== sourceFilter) return false if (assetFilter !== 'all' && t.asset !== assetFilter) return false if (sideFilter !== 'all' && t.side !== sideFilter) return false if (hidePaper && t.is_paper) return false return true }), [trades, sourceFilter, assetFilter, sideFilter, hidePaper]) const totalPages = Math.max(1, Math.ceil(filtered.length / TRADES_PER_PAGE)) const safePage = Math.min(page, totalPages) const pageRows = filtered.slice((safePage - 1) * TRADES_PER_PAGE, safePage * TRADES_PER_PAGE) // Exclude externally-closed trades (pnl_usd null) from aggregates. const priced = filtered.filter(t => t.pnl_usd !== null && t.pnl_usd !== undefined) const totalPnl = priced.reduce((s, t) => s + (t.pnl_usd ?? 0), 0) const wins = priced.filter(t => (t.pnl_usd ?? 0) > 0).length const losses = priced.length - wins const avgHold = filtered.length > 0 ? Math.round(filtered.reduce((s, t) => s + t.hold_seconds, 0) / filtered.length) : 0 return ( <> {/* ── Per-source breakdown (the "which module makes money" view) ── */} {sources.length > 1 && (
{isZh ? '按信号来源拆分盈亏' : 'P&L by signal source'}
{sources.map(src => { const s = perSource[src] const winRate = s.trades ? (s.wins / s.trades) * 100 : 0 const tone = s.pnl > 0 ? 'up' : s.pnl < 0 ? 'down' : 'idle' const bg = tone === 'up' ? 'var(--up-soft)' : tone === 'down' ? 'var(--down-soft)' : 'var(--bg-sunk)' const fg = tone === 'up' ? 'var(--up)' : tone === 'down' ? 'var(--down)' : 'var(--ink-2)' return ( ) })}
{isZh ? '点击来源可筛选表格。' : 'Click a source to filter the table.'} {sourceFilter !== 'all' && ( )}
)} {/* KPI row */}
{isZh ? '总交易数' : 'Total trades'}
{filtered.length}
{isZh ? '胜率' : 'Win rate'}
{priced.length ? ((wins / priced.length) * 100).toFixed(1) + '%' : '—'}
{isZh ? `${wins} 赢 · ${losses} 亏` : `${wins}W · ${losses}L`}
{isZh ? '净盈亏' : 'Net P&L'}
{fmtMoney(totalPnl, { sign: true, decimals: 0 })}
{isZh ? '平均持仓' : 'Avg hold'}
{avgHold ? fmtHold(avgHold) : '—'}
{/* Filters */}
{assets.map(a => ( ))}
{SIDES.map(s => ( ))}
{/* Hide-paper toggle: paper trades inflate volume but aren't real $ — */} {/* let users blend them out before reading the KPI row. */}
{/* Loading state */} {loading && (
{isZh ? '加载中…' : 'Loading…'}
)} {/* Table */} {!loading && (
{filtered.length === 0 && ( )} {pageRows.map(t => { const tp = posts.find(p => p.id === t.trigger_post_id) const roi = t.entry_price && t.exit_price != null ? ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1) : 0 const src = t.trigger_source || 'unknown' return ( ) })}
{isZh ? '来源' : 'Source'} {isZh ? '资产' : 'Asset'} {isZh ? '方向' : 'Side'} {isZh ? '开仓' : 'Entry'} {isZh ? '平仓' : 'Exit'} {isZh ? '持仓' : 'Hold'} {isZh ? '触发内容' : 'Trigger'} P&L
{isZh ? '没有符合条件的交易。' : 'No trades found'}
{src} {t.is_paper && ( PAPER )}
{t.asset}
{t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')} {t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'} {t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'} {fmtHold(t.hold_seconds)} {tp ? ( {tp.text.slice(0, 60)}… ) : ( )}
{t.pnl_usd === null || t.pnl_usd === undefined ? ( n/a ) : ( <> = 0 ? 'up' : 'down'}`} style={{ fontWeight: 600, fontSize: 14 }}> {fmtMoney(t.pnl_usd, { sign: true })} = 0 ? 'up' : 'down'}`} style={{ fontSize: 11, opacity: 0.7 }}> {fmtPct(roi)} )}
)} {/* Pagination */} {!loading && ( )} ) }