Production polish: i18n shelved, PWA icons, SEO/GEO cleanup, UX fixes
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>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import { getPerformance, getTrades, getSignalAccuracy } from '@/lib/api'
|
||||
import type { BotPerformance, BotTrade } from '@/types'
|
||||
import type { SignalAccuracy } from '@/lib/api'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope } from '@/lib/signedRequest'
|
||||
|
||||
type Period = '7d' | '30d' | '90d' | 'All'
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
if (n == null || isNaN(n)) return '—'
|
||||
const abs = Math.abs(n)
|
||||
const s = abs.toLocaleString('en-US', { maximumFractionDigits: 0 })
|
||||
if (n < 0) return '-$' + s
|
||||
if (n > 0) return '+$' + s
|
||||
return '$' + s
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function inPeriod(iso: string, period: Period) {
|
||||
if (period === 'All') return true
|
||||
const days = Number.parseInt(period, 10)
|
||||
if (Number.isNaN(days)) return true
|
||||
const time = new Date(iso).getTime()
|
||||
return time >= Date.now() - days * 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
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())
|
||||
let equity = 0
|
||||
let peak = 0
|
||||
let maxDrawdownPct = 0
|
||||
|
||||
for (const trade of ordered) {
|
||||
equity += trade.pnl_usd as number
|
||||
peak = Math.max(peak, equity)
|
||||
if (peak > 0) {
|
||||
maxDrawdownPct = Math.max(maxDrawdownPct, ((peak - equity) / peak) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
return maxDrawdownPct
|
||||
}
|
||||
|
||||
export default function AnalyticsPageClient() {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const { address, isConnected } = useAccount()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
const [perf, setPerf] = useState<BotPerformance | null>(null)
|
||||
const [trades, setTrades] = useState<BotTrade[]>([])
|
||||
const [accuracy, setAccuracy] = useState<SignalAccuracy | null>(null)
|
||||
const [period, setPeriod] = useState<Period>('30d')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (!address || !isConnected) {
|
||||
setPerf(null)
|
||||
setTrades([])
|
||||
setAccuracy(null)
|
||||
return
|
||||
}
|
||||
;(async () => {
|
||||
try {
|
||||
const perfEnv = getCachedViewEnvelope('view_performance', address)
|
||||
?? await getOrCreateViewEnvelope({ action: 'view_performance', wallet: address, signMessageAsync })
|
||||
const tradesEnv = getCachedViewEnvelope('view_trades', address)
|
||||
?? await getOrCreateViewEnvelope({ action: 'view_trades', wallet: address, signMessageAsync })
|
||||
const [p, t, a] = await Promise.all([
|
||||
getPerformance(address, perfEnv).catch(() => null),
|
||||
getTrades(address, tradesEnv, 100, 1).catch(() => []),
|
||||
getSignalAccuracy().catch(() => null),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setPerf(p)
|
||||
setTrades(t)
|
||||
setAccuracy(a)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPerf(null)
|
||||
setTrades([])
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [address, isConnected, signMessageAsync])
|
||||
|
||||
const filteredTrades = trades.filter((trade) => inPeriod(trade.closed_at, period))
|
||||
const pricedTrades = filteredTrades.filter(
|
||||
(t) => t.pnl_usd !== null && t.pnl_usd !== undefined,
|
||||
)
|
||||
const pnls = pricedTrades.map((t) => t.pnl_usd as number)
|
||||
const totalPnl = pnls.reduce((s, p) => s + p, 0)
|
||||
const wins = pnls.filter((p) => p > 0).length
|
||||
const winRate = pricedTrades.length ? (wins / pricedTrades.length) * 100 : 0
|
||||
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 maxDrawdown = period === '30d' && perf ? perf.max_drawdown_pct : calcDrawdownPct(filteredTrades)
|
||||
const summaryPnl = period === '30d' && perf && trades.length >= filteredTrades.length
|
||||
? perf.net_pnl_usd
|
||||
: totalPnl
|
||||
|
||||
const metrics = [
|
||||
{ k: isZh ? '最大回撤' : 'Max drawdown', v: filteredTrades.length ? maxDrawdown.toFixed(1) + '%' : '—', sub: isZh ? '从高点到低点的最大跌幅' : 'Worst peak-to-trough', down: true },
|
||||
{ k: isZh ? '总交易数' : 'Total trades', v: String(filteredTrades.length || '—'), sub: isZh ? '已平仓机器人交易' : 'Closed bot executions' },
|
||||
{ k: isZh ? '平均持仓时间' : 'Avg hold time', v: filteredTrades.length ? fmtHold(Math.round(avgHold)) : '—', sub: isZh ? '按单笔计算' : 'Per trade' },
|
||||
{ k: isZh ? '平均单笔盈亏' : 'Avg trade P&L', v: fmtMoney(avgTrade), sub: isZh ? '每笔交易均值' : 'Mean per trade', up: avgTrade > 0 },
|
||||
{ k: isZh ? '最佳单笔' : 'Best trade', v: fmtMoney(bestTrade), sub: isZh ? '单笔最大盈利' : 'Largest single win', up: true },
|
||||
{ k: isZh ? '最差单笔' : 'Worst trade', v: fmtMoney(worstTrade), sub: isZh ? '单笔最大亏损' : 'Largest single loss', down: true },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">{isZh ? '分析面板' : 'Analytics'}</h1>
|
||||
<p className="page-sub">{isZh ? `查看 ${period} 窗口内的信号与交易表现。` : `Deep dive into ${period} of signals and trades.`}</p>
|
||||
</div>
|
||||
<div className="nav-tabs">
|
||||
{(['7d', '30d', '90d', 'All'] as const).map(p => (
|
||||
<button key={p} className={`nav-tab ${period === p ? 'active' : ''}`} onClick={() => setPeriod(p)}>{p}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 28, marginBottom: 20 }}>
|
||||
<div className="row between" style={{ alignItems: 'flex-start', marginBottom: 20 }}>
|
||||
<div>
|
||||
<div className="tiny">{isZh ? '表现' : 'Performance'} · {period}</div>
|
||||
<div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}>
|
||||
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
|
||||
</div>
|
||||
<div className="row gap-s" style={{ marginTop: 8 }}>
|
||||
<span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}</span>
|
||||
<span className="chip">{isZh ? `${filteredTrades.length} 笔交易` : `${filteredTrades.length} trades`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metric-grid" style={{ marginBottom: 20 }}>
|
||||
{metrics.map(m => (
|
||||
<div key={m.k} className="card" style={{ padding: 20 }}>
|
||||
<div className="tiny" style={{ marginBottom: 8 }}>{m.k}</div>
|
||||
<div className={`mono ${m.up ? 'delta up' : m.down ? 'delta down' : ''}`} style={{ fontSize: 24, fontWeight: 500, letterSpacing: '-0.01em' }}>{m.v}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{m.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</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' }}>{pct.toFixed(0)}%</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' }}>{pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 6 }}>{isZh ? `${data.m5.checked} 条已测` : `${data.m5.checked} measured`}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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.')
|
||||
: 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.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user