Files

408 lines
18 KiB
TypeScript

'use client'
import { useState, useMemo, useEffect } from 'react'
import { useLocale } from 'next-intl'
import type { BotTrade } 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 | null | undefined) {
if (s == null) return '—'
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[]
loading: boolean
locked?: boolean // true = waiting for wallet signature, not "no trades"
}
// 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
// Human-readable labels for raw signal-source identifiers. Keeps the trade
// history readable for non-technical users (matches PostCards source labels).
const SOURCE_LABEL: Record<string, string> = {
truth: 'Trump',
btc_bottom_reversal: 'BTC Macro Bottom',
funding_reversal: 'Funding Reversal',
kol_divergence: 'KOL Divergence',
sma_reclaim: 'SMA Reclaim',
rsi_reversal: 'RSI Reversal',
breakout: 'Breakout',
adopted: 'Adopted',
manual: 'Manual',
unknown: 'Unknown',
}
function sourceLabel(src: string): string {
return SOURCE_LABEL[src?.toLowerCase()] ?? src
}
const TRADES_PER_PAGE = 25
export default function TradeTable({ trades, loading, locked = false }: 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<string>()
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<string>()
for (const t of trades) set.add(t.trigger_source || 'unknown')
return Array.from(set).sort()
}, [trades])
// Reset filters that no longer apply to the loaded trade set. On a wallet
// switch the trades prop changes but the local filter state persists, so a
// source/asset/side the previous wallet had could stay "stuck" — and when
// the new wallet has a single source the breakdown card (sources.length > 1)
// disappears, removing the only Clear affordance. Clamping here guarantees
// the user always sees their full new history.
useEffect(() => {
if (sourceFilter !== 'all' && !sources.includes(sourceFilter)) setSourceFilter('all')
if (assetFilter !== 'all' && !assets.includes(assetFilter)) setAssetFilter('all')
}, [sources, assets, sourceFilter, assetFilter])
// Per-source PnL aggregate — the critical view for "which module makes money".
// Apply asset/side/paper filters for the source breakdown so the cards stay
// consistent with the KPI and table rows below. We intentionally do NOT apply
// sourceFilter here — filtering by source would trivially make one card 100%.
const filteredForSources = useMemo(() => trades.filter(t => {
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, assetFilter, sideFilter, hidePaper])
const perSource = useMemo(() => {
const acc: Record<string, { trades: number; pnl: number; wins: number; paper: number }> = {}
for (const t of filteredForSources) {
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
}, [filteredForSources])
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 withHold = filtered.filter(t => t.hold_seconds !== null)
const avgHold = withHold.length > 0
? Math.round(withHold.reduce((s, t) => s + (t.hold_seconds ?? 0), 0) / withHold.length)
: 0
return (
<>
{/* ── Per-source breakdown (the "which module makes money" view) ── */}
{sources.length > 1 && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{
fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 10,
}}>
{isZh ? '按信号来源拆分盈亏' : 'Which signal makes money'}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: 10,
}}>
{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 (
<button
key={src}
onClick={() => { setSourceFilter(sourceFilter === src ? 'all' : src); setPage(1) }}
style={{
textAlign: 'left', cursor: 'pointer',
background: bg, borderRadius: 8, padding: '12px 14px',
border: `1px solid ${sourceFilter === src ? fg : 'transparent'}`,
}}
>
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{sourceLabel(src)}
{s.paper > 0 && (
<span style={{
fontSize: 9, marginLeft: 6, padding: '1px 5px',
borderRadius: 3, background: 'rgba(245,158,11,0.15)',
color: '#f59e0b',
}}>
{s.paper}P
</span>
)}
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: fg, fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(s.pnl, { sign: true, decimals: 0 })}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 2 }}>
{isZh ? `${s.trades} 笔交易 · ${winRate.toFixed(0)}% 胜率` : `${s.trades} trades · ${winRate.toFixed(0)}% win`}
</div>
</button>
)
})}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-4)', marginTop: 8 }}>
{isZh ? '点击来源可筛选表格。' : 'Click a source to filter the table.'} {sourceFilter !== 'all' && (
<button
onClick={() => { setSourceFilter('all'); setPage(1) }}
style={{ marginLeft: 8, padding: '2px 8px', fontSize: 10,
border: '1px solid var(--line)', borderRadius: 4,
background: 'transparent', cursor: 'pointer', color: 'var(--ink-2)' }}
>
{isZh ? `清除(当前为 “${sourceLabel(sourceFilter)}”)` : `Clear (showing “${sourceLabel(sourceFilter)}”)`}
</button>
)}
</div>
</div>
)}
{/* KPI row */}
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4,1fr)', marginBottom: 20 }}>
<div className="kpi">
<div className="label">{isZh ? '总交易数' : 'Total trades'}</div>
<div className="value">{filtered.length}</div>
</div>
<div className="kpi">
<div className="label">{isZh ? '胜率' : 'Win rate'}</div>
<div className="value">{priced.length ? ((wins / priced.length) * 100).toFixed(1) + '%' : '—'}</div>
<div className="foot"><span>{isZh ? `${wins} 赢 · ${losses}` : `${wins}W · ${losses}L`}</span></div>
</div>
<div className="kpi accent">
<div className="label">{isZh ? '净盈亏' : 'Net P&L'}</div>
<div className="value">{fmtMoney(totalPnl, { sign: true, decimals: 0 })}</div>
</div>
<div className="kpi">
<div className="label">{isZh ? '平均持仓' : 'Avg hold'}</div>
<div className="value">{avgHold ? fmtHold(avgHold) : '—'}</div>
</div>
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginBottom: 14 }}>
<div className="nav-tabs">
{assets.map(a => (
<button
key={a}
className={`nav-tab ${assetFilter === a ? 'active' : ''}`}
onClick={() => { setAssetFilter(a); setPage(1) }}
>
{a === 'all' ? (isZh ? '全部资产' : 'All assets') : a}
</button>
))}
</div>
<div className="nav-tabs">
{SIDES.map(s => (
<button
key={s}
className={`nav-tab ${sideFilter === s ? 'active' : ''}`}
onClick={() => { setSideFilter(s); setPage(1) }}
>
{s === 'all' ? (isZh ? '全部方向' : 'All') : s === 'long' ? (isZh ? '做多' : 'Long') : (isZh ? '做空' : 'Short')}
</button>
))}
</div>
{/* Hide-paper toggle: paper trades inflate volume but aren't real $ — */}
{/* let users blend them out before reading the KPI row. */}
<label style={{
display: 'flex', alignItems: 'center', gap: 6, fontSize: 12,
color: 'var(--ink-3)', cursor: 'pointer', marginLeft: 'auto',
}}>
<input
type="checkbox"
checked={hidePaper}
onChange={e => { setHidePaper(e.target.checked); setPage(1) }}
/>
{isZh ? '隐藏模拟交易' : 'Hide paper trades'}
</label>
</div>
{/* Loading state */}
{loading && (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>{isZh ? '加载中…' : 'Loading…'}</div>
)}
{/* Table */}
{!loading && (
<div className="card flush" style={{ overflow: 'hidden' }}>
<table className="table">
<thead>
<tr>
{/* P&L right after Side — it's the column this page exists for.
As the last of 8 columns it sat off-screen on phones (the
table scrolls inside its card) until the user swiped past
entry/exit/hold/trigger to find it. */}
<th>{isZh ? '来源' : 'Source'}</th>
<th>{isZh ? '资产' : 'Asset'}</th>
<th>{isZh ? '方向' : 'Side'}</th>
<th>P&amp;L</th>
<th>{isZh ? '开仓' : 'Entry'}</th>
<th>{isZh ? '平仓' : 'Exit'}</th>
<th>{isZh ? '持仓' : 'Hold'}</th>
<th>{isZh ? '触发信号' : 'What triggered it'}</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<td colSpan={8} style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}>
{locked
? (isZh ? '签名解锁后即可查看交易历史。' : 'Sign to unlock your trade history above.')
: (isZh ? '没有符合条件的交易。' : 'No trades found')}
</td>
</tr>
)}
{pageRows.map(t => {
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 (
<tr key={t.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
padding: '2px 7px', borderRadius: 4,
background: 'var(--bg-sunk)',
}}>
{sourceLabel(src)}
</span>
{t.is_paper && (
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 3,
background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
}}>
PAPER
</span>
)}
</div>
</td>
<td>
<div className="row gap-s">
<span className={`asset-dot ${t.asset.toLowerCase()}`} />
<span style={{ fontWeight: 500 }}>{t.asset}</span>
</div>
</td>
<td>
<span className={`side-pill ${t.side}`}>
{t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')}
</span>
</td>
<td>
<div className="stack" style={{ alignItems: 'flex-start' }}>
{t.pnl_usd === null || t.pnl_usd === undefined ? (
<span
style={{ fontSize: 12, color: 'var(--ink-4)' }}
title={isZh ? '在 Hyperliquid 外部平仓,PnL 未记录' : 'Closed externally on Hyperliquid — PnL not recorded'}
>
n/a
</span>
) : (
<>
<span className={`delta ${t.pnl_usd >= 0 ? 'up' : 'down'}`} style={{ fontWeight: 600, fontSize: 14 }}>
{fmtMoney(t.pnl_usd, { sign: true })}
</span>
<span className={`delta ${roi >= 0 ? 'up' : 'down'}`} style={{ fontSize: 11, opacity: 0.7 }}>
{fmtPct(roi)}
</span>
</>
)}
</div>
</td>
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 260 }}>
{t.trigger_post_text ? (
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{t.trigger_post_text.slice(0, 60)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{!loading && (
<Pagination
page={safePage}
total={totalPages}
count={filtered.length}
pageSize={TRADES_PER_PAGE}
onChange={setPage}
scrollTop={false}
/>
)}
</>
)
}