KOL count: 29 → 25 across marketing/SEO copy

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>
This commit is contained in:
k
2026-06-09 22:55:27 +08:00
parent 9e0f6554cb
commit 4c3c8c6f87
57 changed files with 3464 additions and 1855 deletions
+91 -64
View File
@@ -7,6 +7,7 @@ import { getTrades, getSignalAccuracy } from '@/lib/api'
import type { BotTrade } from '@/types'
import type { SignalAccuracy } from '@/lib/api'
import { getCachedViewEnvelope, getOrCreateViewEnvelope, type SignedEnvelope } from '@/lib/signedRequest'
import { swrFetch } from '@/lib/cache'
import PageHint from '@/components/ui/PageHint'
import InfoTip from '@/components/ui/InfoTip'
@@ -32,7 +33,8 @@ function fmtAccuracyPct(pct: number | null | undefined) {
return pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%`
}
function inPeriod(iso: string, period: Period) {
function inPeriod(iso: string | null, period: Period) {
if (!iso) return false // trades without closed_at are excluded from all windows
if (period === 'All') return true
const days = Number.parseInt(period, 10)
if (Number.isNaN(days)) return true
@@ -42,8 +44,8 @@ function inPeriod(iso: string, period: Period) {
function calcDrawdownPct(trades: BotTrade[]) {
const ordered = [...trades]
.filter((t) => t.pnl_usd !== null && t.pnl_usd !== undefined)
.sort((a, b) => new Date(a.closed_at).getTime() - new Date(b.closed_at).getTime())
.filter((t) => t.pnl_usd !== null && t.pnl_usd !== undefined && t.closed_at !== null)
.sort((a, b) => new Date(a.closed_at!).getTime() - new Date(b.closed_at!).getTime())
let equity = 0
let peak = 0
let maxDrawdownPct = 0
@@ -78,6 +80,14 @@ export default function AnalyticsPageClient() {
return () => { aliveRef.current = false }
}, [])
// genRef guards loadAll against stale-closure wallet-switch races.
// snapAddr === address inside an async function is a stale-closure trap:
// both refer to the same closed-over value at render time, so the check
// is always true even when the wallet has changed. genRef is a mutable
// ref that any closure can read to detect it has been superseded.
const genRef = useRef(0)
useEffect(() => { genRef.current++ }, [address])
// `forcedEnv` (a freshly-minted view_user) lets the in-page Unlock button
// load private data without a detour through the Settings page. Public
// signal-accuracy always loads regardless.
@@ -89,25 +99,37 @@ export default function AnalyticsPageClient() {
// disagreed with the closed_at-based metric grid on the same screen. Limit
// raised to 500 so the local computation covers ample history.
async function loadAll(forcedEnv?: SignedEnvelope) {
const accuracyPromise = swrFetch('signal-accuracy', 10 * 60_000, () => getSignalAccuracy()).catch(() => null)
if (!address || !isConnected) {
setTrades([]); setAccuracy(null); setPrivateLocked(false)
setTrades([]); setPrivateLocked(false)
const a = await accuracyPromise
if (aliveRef.current) setAccuracy(a)
return
}
const tradesEnv = getCachedViewEnvelope('view_trades', address)
?? (forcedEnv ?? getCachedViewEnvelope('view_user', address))
const snapAddr = address
const gen = genRef.current
const tradesEnv = getCachedViewEnvelope('view_trades', snapAddr)
?? (forcedEnv ?? getCachedViewEnvelope('view_user', snapAddr))
if (aliveRef.current) setPrivateLocked(!tradesEnv)
try {
const [t, a] = await Promise.all([
tradesEnv ? getTrades(address, tradesEnv, 500, 1).catch(() => []) : Promise.resolve([]),
getSignalAccuracy().catch(() => null),
tradesEnv ? getTrades(snapAddr, tradesEnv, 500, 1).catch(() => []) : Promise.resolve([]),
accuracyPromise,
])
if (aliveRef.current) { setTrades(t); setAccuracy(a) }
// Discard if unmounted or if a newer loadAll call has started (wallet/tab change).
if (aliveRef.current && gen === genRef.current) { setTrades(t); setAccuracy(a) }
} catch {
if (aliveRef.current) setTrades([])
if (aliveRef.current && gen === genRef.current) setTrades([])
}
}
useEffect(() => {
// B28/B36: clear stale previous-wallet data immediately before the async
// fetch so the UI never shows another wallet's private P&L.
setTrades([])
setPrivateLocked(false)
// Navigation only uses a cached envelope — never auto-popup the wallet.
void loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -157,7 +179,8 @@ export default function AnalyticsPageClient() {
const bestTrade = pnls.length ? Math.max(...pnls) : 0
const worstTrade = pnls.length ? Math.min(...pnls) : 0
const avgTrade = pnls.length ? totalPnl / pnls.length : 0
const avgHold = filteredTrades.length ? filteredTrades.reduce((sum, trade) => sum + trade.hold_seconds, 0) / filteredTrades.length : 0
const heldTrades = filteredTrades.filter(t => t.hold_seconds !== null)
const avgHold = heldTrades.length ? heldTrades.reduce((sum, trade) => sum + (trade.hold_seconds ?? 0), 0) / heldTrades.length : 0
// One basis for every window: derive from the closed_at-filtered trades.
const maxDrawdown = calcDrawdownPct(filteredTrades)
const summaryPnl = totalPnl
@@ -169,11 +192,11 @@ export default function AnalyticsPageClient() {
tip: 'How many closed positions in this window. Open trades not counted.' },
{ k: isZh ? '平均持仓时间' : 'Avg hold time', v: filteredTrades.length ? fmtHold(Math.round(avgHold)) : '—', sub: isZh ? '按单笔计算' : 'Per trade',
tip: 'Entry → exit duration averaged across every closed trade.' },
{ k: isZh ? '平均单笔盈亏' : 'Avg trade P&L', v: fmtMoney(avgTrade), sub: isZh ? '每笔交易均值' : 'Mean per trade', up: avgTrade > 0,
{ k: isZh ? '平均单笔盈亏' : 'Avg trade P&L', v: pnls.length ? fmtMoney(avgTrade) : '—', sub: isZh ? '每笔交易均值' : 'Mean per trade', up: avgTrade > 0,
tip: 'Total P&L ÷ number of trades. Positive = the strategy has edge per trade.' },
{ k: isZh ? '最佳单笔' : 'Best trade', v: fmtMoney(bestTrade), sub: isZh ? '单笔最大盈利' : 'Largest single win', up: true,
{ k: isZh ? '最佳单笔' : 'Best trade', v: pnls.length ? fmtMoney(bestTrade) : '—', sub: isZh ? '单笔最大盈利' : 'Largest single win', up: true,
tip: 'Biggest realized gain on one trade in this window.' },
{ k: isZh ? '最差单笔' : 'Worst trade', v: fmtMoney(worstTrade), sub: isZh ? '单笔最大亏损' : 'Largest single loss', down: true,
{ k: isZh ? '最差单笔' : 'Worst trade', v: pnls.length ? fmtMoney(worstTrade) : '—', sub: isZh ? '单笔最大亏损' : 'Largest single loss', down: true,
tip: 'Biggest realized loss on one trade. Should be bounded by your stop-loss setting.' },
]
@@ -183,8 +206,7 @@ export default function AnalyticsPageClient() {
<div>
<h1 className="page-title">{isZh ? '分析面板' : 'Analytics'}</h1>
<PageHint>
Did the bot actually make money? Win rate, drawdown, average trade,
and AI signal accuracy across the time window you pick on the right.
P&amp;L · win rate · drawdown · signal accuracy pick a time window on the right.
</PageHint>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
@@ -207,6 +229,55 @@ export default function AnalyticsPageClient() {
</div>
</div>
{/* Signal accuracy — always public, shown first so non-connected visitors
can immediately see proof of signal quality without needing to log in. */}
{accuracy && (
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 14, flexWrap: 'wrap' }}>
<div className="tiny">AI Signal Accuracy</div>
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>{accuracy.total_directional_signals} directional signals measured</span>
<span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 600, marginLeft: 'auto' }}>Public · no login needed</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8 }}>
{/* Overall */}
<div style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '12px 14px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>Overall</div>
{(['m5','m15','m1h'] as const).map(w => {
const d = accuracy.overall[w]
const pct = d.accuracy_pct
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
return (
<div key={w} className="row between" style={{ marginBottom: 3 }}>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span>
<span style={{ fontSize: 13, fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>{pct != null ? `${pct.toFixed(0)}%` : '—'}</span>
</div>
)
})}
</div>
{Object.entries(accuracy.by_signal).map(([sig, data]) => {
const label = sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell'
return (
<div key={sig} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '12px 14px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{label} <span style={{ fontWeight: 400 }}>({data.count})</span></div>
{(['m5','m15','m1h'] as const).map(w => {
const d = data[w]
if (d.checked < 2) return <div key={w} className="row between" style={{ marginBottom: 3 }}><span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span><span style={{ fontSize: 12, color: 'var(--ink-3)' }}></span></div>
const pct = d.accuracy_pct
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
return (
<div key={w} className="row between" style={{ marginBottom: 3 }}>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span>
<span style={{ fontSize: 13, fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>{pct.toFixed(0)}%</span>
</div>
)
})}
</div>
)
})}
</div>
</div>
)}
{isPaperView && filteredTrades.length > 0 && (
<div className="card" style={{
padding: '10px 16px', marginBottom: 16, fontSize: 12, fontWeight: 600,
@@ -265,60 +336,16 @@ export default function AnalyticsPageClient() {
))}
</div>
{accuracy && (
<div className="card" style={{ padding: 24, marginBottom: 20 }}>
<div className="tiny" style={{ marginBottom: 16 }}>{isZh ? `AI 信号准确率 · ${accuracy.total_directional_signals} 条方向性信号` : `AI Signal Accuracy · ${accuracy.total_directional_signals} directional signals`}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
<div style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{isZh ? '整体' : 'Overall'}</div>
{(['m5','m15','m1h'] as const).map(w => {
const d = accuracy.overall[w]
const pct = d.accuracy_pct
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
return (
<div key={w} className="row between" style={{ marginBottom: 4 }}>
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{w.replace('m','').replace('1h','1h')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color, fontVariantNumeric: 'tabular-nums' }}>{fmtAccuracyPct(pct)}</span>
</div>
)
})}
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 6 }}>{isZh ? `${accuracy.overall.m5.checked} 条已测` : `${accuracy.overall.m5.checked} measured`}</div>
</div>
{Object.entries(accuracy.by_signal).map(([sig, data]) => {
const label = sig === 'buy' ? (isZh ? '🟢 做多' : '🟢 Buy') : sig === 'short' ? (isZh ? '🔴 做空' : '🔴 Short') : (isZh ? '🟡 卖出' : '🟡 Sell')
return (
<div key={sig} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{label} <span style={{ fontWeight: 400 }}>({data.count})</span></div>
{(['m5','m15','m1h'] as const).map(w => {
const d = data[w]
if (d.checked < 2) return (
<div key={w} className="row between" style={{ marginBottom: 4 }}>
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{w}</span>
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}></span>
</div>
)
const pct = d.accuracy_pct
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
return (
<div key={w} className="row between" style={{ marginBottom: 4 }}>
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{w}</span>
<span style={{ fontSize: 13, fontWeight: 600, color, fontVariantNumeric: 'tabular-nums' }}>{fmtAccuracyPct(pct)}</span>
</div>
)
})}
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 6 }}>{isZh ? `${data.m5.checked} 条已测` : `${data.m5.checked} measured`}</div>
</div>
)
})}
</div>
</div>
)}
{/* Signal accuracy is now shown at the top of the page (public, no login needed).
Removed duplicate block here to avoid showing the same data twice. */}
{!filteredTrades.length && (
<div className="card" style={{ padding: 60, textAlign: 'center', color: 'var(--ink-3)' }}>
<p style={{ fontSize: 14 }}>
{!isConnected || !address
? (isZh ? '连接钱包以加载你的分析数据。' : 'Connect your wallet to load your analytics.')
: privateLocked
? (isZh ? '签名解锁后可查看你的真实业绩和交易历史。' : 'Sign once above to unlock your personal P&L and trade history.')
: trades.length
? (isZh ? `${period} 时间窗内还没有已平仓交易。` : `No trades closed in the ${period} window yet.`)
: (isZh ? '还没有交易数据。机器人开始执行后,这里会自动出现统计。' : 'No trade data yet. The bot will populate analytics once it starts executing.')}
+28
View File
@@ -0,0 +1,28 @@
/** Instant skeleton while AnalyticsPage loads. */
export default function AnalyticsLoading() {
return (
<div className="page">
<div className="page-head" style={{ marginBottom: 24 }}>
<div className="skeleton sk-title" style={{ width: 160, marginBottom: 8 }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12, marginBottom: 20 }}>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="skeleton-card" style={{ padding: 20 }}>
<div className="skeleton sk-line sk-w-half" style={{ marginBottom: 12 }} />
<div className="skeleton" style={{ height: 40, width: 100 }} />
</div>
))}
</div>
<div className="skeleton-card" style={{ padding: 24 }}>
<div className="skeleton sk-line" style={{ width: 140, marginBottom: 16 }} />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} style={{ display: 'flex', gap: 12, marginBottom: 10 }}>
<div className="skeleton sk-line" style={{ width: 80 }} />
<div className="skeleton sk-line" style={{ width: 60 }} />
<div className="skeleton sk-line sk-w-3q" />
</div>
))}
</div>
</div>
)
}