d72323b1c6
Big-picture changes since 01be8e7:
New routes — /trump /btc /kol /trades /analytics /archive plus four
SEO landing pages (/methodology /glossary /case-studies /contact),
all served under /[locale]. Each has dedicated metadata + JSON-LD.
KOL page (new) — DigestWidget + OnchainWidget + TalkVsTradesWidget,
filter by handle/ticker, click-through to per-post detail with the
original AI-extracted ticker/conviction/quote.
BTC page (new) — tabbed: macro-bottom (AHR999 + 200WMA + Pi Cycle)
and funding-rate reversal, with live sparkline + threshold bands.
Telegram card on Settings — wallet-link code generation, status,
disconnect. Preferences moved into the bot itself (/trump /btc etc.)
so the card stays minimal.
SignalMonitor (new) — ETH/LINK Bollinger breakout monitor in its own
component, shares the singleton WsProvider so no second WS opens.
WS singleton refactor (lib/wsContext) — shared WsProvider + useWsSubscribe
hook. Cleanup now actively closes the socket on unmount; previously the
local `ws` couldn't be reached from cleanup and leaked one connection
per StrictMode remount.
OpenPositions polling no longer pops MetaMask in the background —
splits into load('first') for user-initiated and load('poll') that
uses getCachedViewEnvelope without signing.
i18n shelved — proxy.ts (Next 16 middleware rename) wires next-intl
but only Navbar + layout footer have translations. Rest of UI used
isZh ternary scattered across 28 files. All `const isZh = locale === 'zh'`
flipped to `const isZh = false` so every site renders English; Chinese
branches kept as dead code so revival is one regex away.
LanguageSwitch hidden but file kept. zh-CN hreflang removed from
metadata + sitemap to avoid duplicate-content penalties.
Wallet error handling — lib/walletError.ts: isUserRejection walks EIP-1193
code 4001 + .cause chain; previous string-match for "reject"/"denied"
broke for users running MetaMask in non-English UIs. 12 call sites
migrated across 5 components.
PWA icons — app/icon.tsx + app/apple-icon.tsx render the brand "α" via
Next's ImageResponse so no static PNG asset is required. manifest.ts
references /icon and /apple-icon dynamic routes.
OG image + sitemap + robots — dynamic 1200×630 OG card; robots blocks
both /en/settings and /zh/settings; sitemap only emits /en routes;
JSON-LD covers SoftwareApplication + Organization + 10-Q FAQPage.
Landing page polish — Launch Dashboard button stripped of magnetic
hover + shimmer + lift + glow expansion (multiple users found it busy);
hero scramble alphabet swapped to alphanumeric (was block characters);
"15 KOL feeds" copy updated to 19 in 5 places.
PostCards source icons — new entries for btc_bottom_reversal,
funding_reversal, kol_divergence so they no longer fall through to
the generic "first letter" fallback.
Archive page filter — excludes funding_reversal + kol_divergence
(previously only excluded truth + btc_bottom_reversal so new live
signals leaked into the legacy archive).
Cache + skeleton loading — lib/cache.ts SWR module with per-key TTL,
applied across the major pages so navigation feels instant.
SignConfirmSheet — 4 hardcoded Chinese strings translated to English
(English users were seeing zh-only "需要钱包签名" etc).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import { useLocale } from 'next-intl'
|
|
import type { BotTrade, TrumpPost } from '@/types'
|
|
|
|
// ── 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
|
|
}
|
|
|
|
const ASSETS = ['all', 'BTC', 'ETH', 'SOL'] as const
|
|
const SIDES = ['all', 'long', 'short'] as const
|
|
|
|
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)
|
|
|
|
// 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])
|
|
|
|
// 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<string, { trades: number; pnl: number; wins: number; paper: number }> = {}
|
|
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 = 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
|
|
})
|
|
|
|
// 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 && (
|
|
<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 ? '按信号来源拆分盈亏' : 'P&L by signal source'}
|
|
</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)}
|
|
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',
|
|
}}>
|
|
{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')}
|
|
style={{ marginLeft: 8, padding: '2px 8px', fontSize: 10,
|
|
border: '1px solid var(--line)', borderRadius: 4,
|
|
background: 'transparent', cursor: 'pointer', color: 'var(--ink-2)' }}
|
|
>
|
|
{isZh ? `清除(当前为 “${sourceFilter}”)` : `Clear (showing “${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)}
|
|
>
|
|
{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)}
|
|
>
|
|
{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)}
|
|
/>
|
|
{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 ? '触发内容' : 'Trigger'}</th>
|
|
<th>P&L</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.length === 0 && (
|
|
<tr>
|
|
<td colSpan={8} style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}>
|
|
{isZh ? '没有符合条件的交易。' : 'No trades found'}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{filtered.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 (
|
|
<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)',
|
|
}}>
|
|
{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 }}>
|
|
{tp ? (
|
|
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
|
{tp.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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|