4c3c8c6f87
Backend KOL_FEEDS trimmed from 29 to 25 (dead feeds removed).
Sync all hardcoded count mentions:
- layout.tsx JSON-LD, page.tsx (metric + comparison + copy)
- kol/page.tsx, KolPageClient.tsx ("and 26 more" → "and 22 more")
- glossary/page.tsx, opengraph-image.tsx
- public/llms.txt, llms-full.txt
- drop removed KOLs (Dragonfly Capital, Nic Carter) from named lists
Bundles other in-flight frontend work already in the working tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
404 lines
18 KiB
TypeScript
404 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>
|
|
<th>{isZh ? '来源' : 'Source'}</th>
|
|
<th>{isZh ? '资产' : 'Asset'}</th>
|
|
<th>{isZh ? '方向' : 'Side'}</th>
|
|
<th>{isZh ? '开仓' : 'Entry'}</th>
|
|
<th>{isZh ? '平仓' : 'Exit'}</th>
|
|
<th>{isZh ? '持仓' : 'Hold'}</th>
|
|
<th>{isZh ? '触发信号' : 'What triggered it'}</th>
|
|
<th>P&L</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 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>
|
|
<td>
|
|
<div className="stack" style={{ alignItems: 'flex-end' }}>
|
|
{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>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{!loading && (
|
|
<Pagination
|
|
page={safePage}
|
|
total={totalPages}
|
|
count={filtered.length}
|
|
pageSize={TRADES_PER_PAGE}
|
|
onChange={setPage}
|
|
scrollTop={false}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|