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:
k
2026-05-25 00:53:27 +08:00
parent 01be8e790b
commit d72323b1c6
67 changed files with 11735 additions and 2530 deletions
+126 -20
View File
@@ -1,17 +1,21 @@
'use client'
import { useState, useEffect } from 'react'
import { useAccount } from 'wagmi'
import { useParams } from 'next/navigation'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import type { TrumpPost, BotPerformance, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
import { usePriceSocket } from '@/lib/useRealtimeData'
import { getPrices, getUserPublic } from '@/lib/api'
import { getPerformance, getPrices, getUserPublic } from '@/lib/api'
import { getCachedViewEnvelope, getOrCreateViewEnvelope } from '@/lib/signedRequest'
import ChartPanel from '@/components/dashboard/ChartPanel'
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
import SignalMonitor from '@/components/dashboard/SignalMonitor'
import OpenPositions from '@/components/positions/OpenPositions'
interface Props {
initialPosts: TrumpPost[]
initialPerformance?: BotPerformance
}
// ── Inline post detail panel shown in the right rail ──────────────────────────
@@ -124,19 +128,27 @@ function SelectHint() {
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
<p style={{ fontSize: 13, margin: 0, lineHeight: 1.5 }}>
Click any marker on the chart<br/>or a post below to see details
Click any marker on the chart or a post below to see details
</p>
</div>
)
}
// ── Main dashboard ─────────────────────────────────────────────────────────────
export default function DashboardClient({ initialPosts, initialPerformance }: Props) {
export default function DashboardClient({ initialPosts }: Props) {
const intlLocale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, isSubscribed, hlApiKeySet, botReadiness, livePrices } = useDashboardStore()
function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') }
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const params = useParams()
const locale = (typeof params?.locale === 'string' ? params.locale : 'en')
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
const [performance, setPerformance] = useState<BotPerformance | undefined>(undefined)
const [candles, setCandles] = useState<Candle[]>([])
const [chartErr, setChartErr] = useState('')
const [chartReload, setChartReload] = useState(0)
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
// For 1D: show all posts from selected day
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
@@ -146,6 +158,7 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
setPerformance(undefined)
return
}
// Clear account-scoped bot state immediately when the connected wallet
@@ -162,6 +175,25 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
.catch(() => {})
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness])
useEffect(() => {
if (!isConnected || !address) {
setPerformance(undefined)
return
}
let cancelled = false
;(async () => {
try {
const env = getCachedViewEnvelope('view_performance', address)
?? await getOrCreateViewEnvelope({ action: 'view_performance', wallet: address, signMessageAsync })
const data = await getPerformance(address, env)
if (!cancelled) setPerformance(data)
} catch {
if (!cancelled) setPerformance(undefined)
}
})()
return () => { cancelled = true }
}, [address, isConnected, signMessageAsync])
usePriceSocket({
onPrice: (a, price) => setLivePrice(a, price),
onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 500)),
@@ -169,13 +201,31 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
useEffect(() => {
setCandles([])
setChartErr('')
getPrices(asset, timeframe)
.then(setCandles)
.catch(() => {})
}, [asset, timeframe])
.then(c => { setCandles(c); setChartErr('') })
.catch(e => setChartErr(e instanceof Error ? e.message : (isZh ? '价格数据加载失败' : 'Failed to load price data')))
}, [asset, timeframe, chartReload, isZh])
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
const recentPosts = posts.slice(0, 8)
// Pinned BTC bottom-reversal alert: the rarest, highest-conviction signal.
// Surface the most recent btc_bottom_reversal post fired in the last 21
// days right at the top of the overview so it's never missed.
const btcReversalAlert = (() => {
const cutoff = Date.now() - 21 * 24 * 3600 * 1000
const hits = posts
.filter(p => (p.source || '') === 'btc_bottom_reversal'
&& new Date(p.published_at).getTime() >= cutoff)
.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime())
return hits[0] ?? null
})()
// Show actionable signals first (buy/short), then most recent hold/neutral.
// Cap at 8 total so the list doesn't get too long.
const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, 4)
const recentOthers = posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4)
const recentPosts = [...actionable, ...recentOthers].slice(0, 8)
const lastCandle = candles[candles.length - 1]
// Live price beats the most recent candle close — the candle is only
@@ -205,26 +255,67 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
const todayKey = new Date().toISOString().slice(0, 10)
const signalsToday = posts.filter(p => p.published_at?.slice(0, 10) === todayKey).length
const actionablePosts = posts.filter(p => p.signal === 'buy' || p.signal === 'short').length
const winRate = initialPerformance?.win_rate ?? 0
const netPnl = initialPerformance?.net_pnl_usd ?? 0
const winRate = performance?.win_rate ?? 0
const netPnl = performance?.net_pnl_usd ?? 0
const hasPriceData = candles.length > 0
const hasPerformanceData = Boolean(initialPerformance)
const hasPerformanceData = Boolean(performance)
return (
<div className="page wide">
<div className="page-head">
<div>
<h1 className="page-title">Signal monitor</h1>
<h1 className="page-title">{isZh ? '信号总览' : 'Signal monitor'}</h1>
<p className="page-sub">
{actionablePosts} actionable signals · Auto-trader {botReadiness === 'ready' ? 'ready' : hlApiKeySet ? 'saved' : isSubscribed ? 'subscribed' : 'standby'} · {hasPriceData ? 'Live market context' : 'Waiting for market data'}
{`${actionablePosts} actionable signals · Auto-trader ${botReadiness === 'ready' ? 'ready' : hlApiKeySet ? 'saved' : isSubscribed ? 'subscribed' : 'standby'} · ${hasPriceData ? 'Live market context' : 'Waiting for market data'}`}
</p>
</div>
<div className="row gap-s">
<span className="chip"><span className="live-dot" />Live feed</span>
<span className="chip">{totalPosts} posts tracked</span>
<span className="chip">{`${totalPosts} posts tracked`}</span>
</div>
</div>
{/* Pinned BTC bottom-reversal alert — the rarest / highest-conviction
signal. Always sits ABOVE everything so it's never missed. */}
{btcReversalAlert && (
<a
href={`/${locale}/btc`}
style={{
display: 'block', textDecoration: 'none',
border: '1px solid color-mix(in oklab, var(--up) 45%, var(--line))',
background: 'var(--up-soft, rgba(22,163,74,0.10))',
borderRadius: 12, padding: '14px 18px', marginBottom: 16,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, fontWeight: 800, letterSpacing: '.04em',
color: 'var(--up)', textTransform: 'uppercase' }}>
BTC bottom-reversal signal
</span>
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px',
borderRadius: 999, background: 'var(--up)', color: '#fff' }}>
conf {Math.round(btcReversalAlert.ai_confidence)}
</span>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{timeAgo(btcReversalAlert.published_at)}
</span>
<span style={{ marginLeft: 'auto', fontSize: 12, fontWeight: 700,
color: 'var(--up)' }}>
Open BTC signal
</span>
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 6,
lineHeight: 1.45, overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{btcReversalAlert.text}
</div>
</a>
)}
{/* Open positions — what's on the book right now. Renders only when
a subscribed wallet is connected, so guests see the normal feed. */}
<OpenPositions />
{/* KPI Row */}
<div className="kpi-row">
<div className="kpi">
@@ -238,17 +329,17 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
<div className="kpi">
<div className="label">Signals today</div>
<div className="value">{signalsToday}</div>
<div className="foot"><span>{actionablePosts} actionable total</span></div>
<div className="foot"><span>{`${actionablePosts} actionable total`}</span></div>
</div>
<div className="kpi accent">
<div className="label">30d Net P&amp;L</div>
<div className="label">30d Net P&L</div>
<div className="value">{netPnl >= 0 ? '+$' : '-$'}{Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}</div>
<div className="foot"><span>{hasPerformanceData ? 'Bot performance' : 'Performance pending'}</span></div>
</div>
<div className="kpi">
<div className="label">Win rate</div>
<div className="value">{initialPerformance ? (winRate * 100).toFixed(1) + '%' : '—'}</div>
<div className="foot"><span>{hasPerformanceData ? `${initialPerformance?.total_trades ?? 0} trades` : 'Waiting for trade history'}</span></div>
<div className="value">{performance ? (winRate * 100).toFixed(1) + '%' : '—'}</div>
<div className="foot"><span>{hasPerformanceData ? `${performance?.total_trades ?? 0} trades` : 'Connect wallet to load your trade history'}</span></div>
</div>
</div>
@@ -283,6 +374,15 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
</div>
</div>
{chartErr && (
<div style={{ padding: '8px 12px', margin: '0 0 8px', fontSize: 12,
color: 'var(--down)', background: 'var(--down-soft, rgba(220,38,38,.08))',
borderRadius: 8 }}>
{asset} {timeframe} chart failed to load {chartErr}{' '}
<button className="btn ghost" style={{ fontSize: 11, padding: '3px 10px', marginLeft: 6 }}
onClick={() => setChartReload(n => n + 1)}>Retry</button>
</div>
)}
<div className="chart-wrap">
<ChartPanel
posts={posts}
@@ -311,7 +411,10 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
<div>
<div className="section-title">
<h2>Recent signals</h2>
<span className="hint">Showing {recentPosts.length} of {totalPosts}</span>
<span className="hint">
{actionable.length > 0 ? `${actionable.length} actionable · ` : ''}
{`${totalPosts} tracked`}
</span>
</div>
<div className="post-stream">
{recentPosts.map(p => (
@@ -429,6 +532,9 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
) : (
<SelectHint />
)}
{/* Breakout signal monitor */}
<SignalMonitor />
</div>
</div>
</div>
+6 -2
View File
@@ -5,6 +5,7 @@ import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { config } from '@/lib/wagmi'
import { WsProvider } from '@/lib/wsContext'
import '@rainbow-me/rainbowkit/styles.css'
interface ProvidersProps {
@@ -15,7 +16,7 @@ export default function Providers({ children }: ProvidersProps) {
const [queryClient] = useState(() => new QueryClient())
return (
<WagmiProvider config={config}>
<WagmiProvider config={config} reconnectOnMount={false}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider
theme={darkTheme({
@@ -25,7 +26,10 @@ export default function Providers({ children }: ProvidersProps) {
overlayBlur: 'small',
})}
>
{children}
{/* Single shared WebSocket — all components subscribe via useWsSubscribe */}
<WsProvider>
{children}
</WsProvider>
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
@@ -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>
)
}
+21 -144
View File
@@ -1,151 +1,28 @@
'use client'
import type { Metadata } from 'next'
import { getLocale } from 'next-intl/server'
import AnalyticsPageClient from './AnalyticsPageClient'
import { useState, useEffect } from 'react'
import { getPerformance, getTrades } from '@/lib/api'
import type { BotPerformance, BotTrade } from '@/types'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
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[]) {
// Skip externally-closed trades (pnl_usd null) — including them as `+ null`
// turns equity into NaN and the whole drawdown chart goes blank.
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)
}
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const title = isZh ? '交易分析与表现' : 'Trading Analytics & Performance'
const description = isZh
? '查看胜率、最大回撤、平均持仓时间、信号准确率和历史交易表现,评估 Trump Alpha 的真实执行质量。'
: 'Review win rate, drawdown, average hold time, signal accuracy, and historical trade performance to evaluate live execution quality.'
return {
title,
description,
alternates: {
canonical: `${siteUrl}/${locale}/analytics`,
languages: {
en: `${siteUrl}/en/analytics`,
},
},
}
return maxDrawdownPct
}
export default function AnalyticsPage() {
const [perf, setPerf] = useState<BotPerformance | null>(null)
const [trades, setTrades] = useState<BotTrade[]>([])
const [period, setPeriod] = useState<Period>('30d')
useEffect(() => {
Promise.all([
getPerformance().catch(() => null),
getTrades(100, 1).catch(() => []),
]).then(([p, t]) => {
setPerf(p)
setTrades(t)
})
}, [])
const filteredTrades = trades.filter((trade) => inPeriod(trade.closed_at, period))
// Trades closed externally on Hyperliquid have pnl_usd === null. Including
// them silently in reduce/Math.max/Math.min produces NaN ("$NaN P&L") and
// also pollutes win_rate. Aggregate only on the priced subset.
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: 'Max drawdown', v: filteredTrades.length ? maxDrawdown.toFixed(1) + '%' : '—', sub: 'Worst peak-to-trough', down: true },
{ k: 'Total trades', v: String(filteredTrades.length || '—'), sub: 'Closed bot executions' },
{ k: 'Avg hold time', v: filteredTrades.length ? fmtHold(Math.round(avgHold)) : '—', sub: 'Per trade' },
{ k: 'Avg trade P&L', v: fmtMoney(avgTrade), sub: 'Mean per trade', up: avgTrade > 0 },
{ k: 'Best trade', v: fmtMoney(bestTrade), sub: 'Largest single win', up: true },
{ k: 'Worst trade', v: fmtMoney(worstTrade), sub: 'Largest single loss', down: true },
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Analytics</h1>
<p className="page-sub">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>
{/* Summary card */}
<div className="card" style={{ padding: 28, marginBottom: 20 }}>
<div className="row between" style={{ alignItems: 'flex-start', marginBottom: 20 }}>
<div>
<div className="tiny">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)}% win rate</span>
<span className="chip">{filteredTrades.length} trades</span>
</div>
</div>
</div>
</div>
{/* Metric grid */}
<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>
{/* No data message */}
{!filteredTrades.length && (
<div className="card" style={{ padding: 60, textAlign: 'center', color: 'var(--ink-3)' }}>
<p style={{ fontSize: 14 }}>
{trades.length
? `No trades closed in the ${period} window yet.`
: 'No trade data yet. The bot will populate analytics once it starts executing.'}
</p>
</div>
)}
</div>
)
return <AnalyticsPageClient />
}
+110
View File
@@ -0,0 +1,110 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useLocale } from 'next-intl'
import type { TrumpPost } from '@/types'
import { getPosts } from '@/lib/api'
import PostRow from '@/components/dashboard/PostCards'
/**
* Archive — legacy / test signals (rsi_reversal, sma_reclaim, breakout,
* test, phase1…). NOT a live system. Kept only so old data is inspectable.
*/
export default function ArchivePage() {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const [posts, setPosts] = useState<TrumpPost[]>([])
const [loading, setLoading] = useState(true)
const [loadErr, setLoadErr] = useState('')
const [src, setSrc] = useState<string>('all')
useEffect(() => {
getPosts(500, 1)
.then(p => { setPosts(p); setLoadErr('') })
.catch(e => setLoadErr(e instanceof Error ? e.message : (isZh ? '历史归档加载失败' : 'Failed to load archive')))
.finally(() => setLoading(false))
}, [isZh])
// Archive = legacy / test data only. Exclude every live signal source so
// active modules don't leak in here as users explore old experiments. Keep
// this set in sync with sources emitted by app/services/scanners/*.
const LIVE_SOURCES = new Set([
'truth',
'btc_bottom_reversal',
'funding_reversal',
'kol_divergence',
])
const archivePosts = useMemo(
() => posts.filter(p => !LIVE_SOURCES.has(p.source || '')),
[posts],
)
const sources = useMemo(() => {
const m: Record<string, number> = {}
for (const p of archivePosts) m[p.source || '?'] = (m[p.source || '?'] || 0) + 1
return Object.entries(m).sort((a, b) => b[1] - a[1])
}, [archivePosts])
const filtered = useMemo(
() => src === 'all' ? archivePosts : archivePosts.filter(p => p.source === src),
[archivePosts, src],
)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Archive</h1>
<p className="page-sub">{isZh ? '历史 / 测试数据,不属于实时系统。' : 'Legacy / test data — NOT a live system'}</p>
</div>
</div>
<div style={{
background: 'var(--bg-sunk)', borderRadius: 10, padding: '12px 16px',
marginBottom: 16, borderLeft: '4px solid var(--ink-4)',
}}>
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5 }}>
{isZh
? '这些来源已经被 BTC 周期底部 2-of-3 共振系统替代,不再参与排程。这里只作为历史查阅使用,机器人不会根据它们执行交易。'
: 'These sources were superseded by the BTC bottom-reversal 2-of-3 price confluence and are no longer scheduled. Shown here only for historical inspection — the bot does not act on them.'}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
{[['all', archivePosts.length] as [string, number], ...sources].map(([s, n]) => (
<button
key={s}
onClick={() => setSrc(s)}
style={{
padding: '4px 10px', borderRadius: 6, border: '1px solid var(--line)',
background: src === s ? 'var(--ink)' : 'transparent',
color: src === s ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: src === s ? 600 : 400,
}}
>
{s} <span style={{ opacity: 0.6 }}>{n}</span>
</button>
))}
</div>
{loading && <div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>{isZh ? '加载中…' : 'Loading…'}</div>}
{!loading && loadErr && (
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--down)' }}>
{isZh ? `无法加载归档:${loadErr}` : `Couldnt load archive — ${loadErr}`}
<div style={{ marginTop: 10 }}>
<button className="btn ghost" style={{ fontSize: 12, padding: '6px 14px' }}
onClick={() => location.reload()}>{isZh ? '重试' : 'Retry'}</button>
</div>
</div>
)}
{!loading && !loadErr && filtered.length === 0 && (
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
{isZh ? '没有可显示的历史信号。' : 'No archived signals.'}
</div>
)}
{!loading && filtered.length > 0 && (
<div className="post-stream">
{filtered.map(p => <PostRow key={p.id} post={p} />)}
</div>
)}
</div>
)
}
+417
View File
@@ -0,0 +1,417 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useLocale } from 'next-intl'
import type { TrumpPost } from '@/types'
import { getPosts, getFundingSnapshot, type FundingSnapshot } from '@/lib/api'
import { swrFetch } from '@/lib/cache'
import PostRow from '@/components/dashboard/PostCards'
import SystemControl from '@/components/signals/SystemControl'
/**
* System 2 — BTC bottom-reversal. Its own dedicated page.
* Shows the scanner control + ONLY source === 'btc_bottom_reversal' signals.
*/
const SENTIMENTS = ['all', 'bullish', 'bearish', 'neutral'] as const
type SentimentFilter = (typeof SENTIMENTS)[number]
type BtcTab = 'bottom' | 'funding'
interface BtcSignalPageProps {
initialPosts?: TrumpPost[] | null
initialFundingSnapshot?: FundingSnapshot | null
}
export default function BtcSignalPage({
initialPosts = null,
initialFundingSnapshot = null,
}: BtcSignalPageProps) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts ?? [])
const [loading, setLoading] = useState(initialPosts === null)
const [loadErr, setLoadErr] = useState('')
const [sentFilter, setSentFilter] = useState<SentimentFilter>('all')
const [tab, setTab] = useState<BtcTab>('bottom')
useEffect(() => {
// BTC on-chain signals update daily — 30 min TTL is safe
swrFetch(
'posts-500',
3 * 60_000,
() => getPosts(500, 1),
fresh => setPosts(fresh),
)
.then(p => { setPosts(p); setLoadErr('') })
.catch(e => setLoadErr(e instanceof Error ? e.message : (isZh ? '信号加载失败' : 'Failed to load signals')))
.finally(() => setLoading(false))
}, [isZh])
// Tab-scoped post source. 'bottom' = MVRV/200WMA confluence,
// 'funding' = funding-rate extreme reversal.
const tabSource = tab === 'bottom' ? 'btc_bottom_reversal' : 'funding_reversal'
const btcPosts = useMemo(
() => posts.filter(p => (p.source || '') === tabSource),
[posts, tabSource],
)
const filtered = useMemo(
() => btcPosts.filter(p => sentFilter === 'all' || p.sentiment === sentFilter),
[btcPosts, sentFilter],
)
const sentimentLabels: Record<SentimentFilter, string> = {
all: isZh ? '全部' : 'All',
bullish: isZh ? '看多' : 'Bullish',
bearish: isZh ? '看空' : 'Bearish',
neutral: isZh ? '中性' : 'Neutral',
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '② BTC 信号' : '② BTC Signal'}</h1>
<p className="page-sub">
{tab === 'bottom'
? `Macro cycle bottom · ${btcPosts.length} signals`
: `Funding-rate extreme reversal · ${btcPosts.length} signals`}
</p>
</div>
<span className="chip"><span className="live-dot" />Live</span>
</div>
<SystemControl system="btc" />
{tab === 'bottom' && (
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6, marginBottom: 12 }}>
This module tracks Bitcoin macro-bottom conditions. It only fires a long-only cycle signal when at least two of three classic bottom markers agree: AHR999, the 200-week moving average, and Pi Cycle Bottom.
</div>
)}
<div className="nav-tabs" style={{
background: 'var(--bg-sunk)', marginTop: 16, marginBottom: 12,
}}>
<button
onClick={() => setTab('bottom')}
className={`nav-tab ${tab === 'bottom' ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer' }}
>
{isZh ? '周期底部' : 'Macro Bottom'}
</button>
<button
onClick={() => setTab('funding')}
className={`nav-tab ${tab === 'funding' ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer' }}
>
{isZh ? '资金费率反转' : 'Funding Reversal'}
</button>
</div>
{tab === 'bottom' && (
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5, marginBottom: 12 }}>
<>
Fires when <strong>2 of 3</strong> classic bottom signals agree:{' '}
<strong>AHR999&nbsp;&lt;&nbsp;0.45</strong> · <strong>price 200-week
MA</strong> · <strong>Pi Cycle Bottom</strong>. Long only, low frequency
(24 fires per cycle). Scans daily 00:45&nbsp;UTC. Designed to ride the
full cycle: holds up to <strong>18&nbsp;months</strong>, trailing-stop
exit, no take-profit. Keep leverage 2× amplification comes from
pyramiding, not from cranking leverage.
</>
</div>
)}
{tab === 'funding' && (
<FundingPanel
isZh={isZh}
initialSnapshot={initialFundingSnapshot}
/>
)}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '16px 0 12px' }}>
{SENTIMENTS.map(f => (
<button
key={f}
onClick={() => setSentFilter(f)}
style={{
padding: '4px 10px', borderRadius: 6, border: '1px solid var(--line)',
background: sentFilter === f ? 'var(--ink)' : 'transparent',
color: sentFilter === f ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: sentFilter === f ? 600 : 400,
}}
>
{sentimentLabels[f]}
</button>
))}
</div>
{loading && <BtcSkeleton />}
{!loading && loadErr && (
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--down)' }}>
{`Couldnt load signals — ${loadErr}`}
<div style={{ marginTop: 10 }}>
<button className="btn ghost" style={{ fontSize: 12, padding: '6px 14px' }}
onClick={() => location.reload()}>{isZh ? '重试' : 'Retry'}</button>
</div>
</div>
)}
{!loading && !loadErr && filtered.length === 0 && (
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
{tab === 'bottom'
? 'No macro-bottom signals yet.'
: 'No funding-reversal signals yet.'}
<div style={{ fontSize: 12, marginTop: 8 }}>
{tab === 'bottom'
? 'This scanner is intentionally rare — only fires at genuine macro bottoms (daily 00:45 UTC).'
: 'Fires when 30-day cumulative funding exceeds ±3% AND starts mean-reverting. See the live panel above for current state.'}
</div>
</div>
)}
{!loading && filtered.length > 0 && (
<div className="post-stream">
{filtered.map(p => <PostRow key={p.id} post={p} />)}
</div>
)}
</div>
)
}
// ── Funding rate live panel ─────────────────────────────────────────────────
function FundingPanel({
isZh,
initialSnapshot = null,
}: {
isZh: boolean
initialSnapshot?: FundingSnapshot | null
}) {
const [snap, setSnap] = useState<FundingSnapshot | null>(initialSnapshot)
const [err, setErr] = useState('')
useEffect(() => {
let alive = true
function load() {
swrFetch(
'funding-snapshot',
5 * 60_000, // 5 min — funding cadence is 18h, no point polling faster
() => getFundingSnapshot(),
fresh => { if (alive) setSnap(fresh) },
)
.then(s => { if (alive) { setSnap(s); setErr('') } })
.catch(e => { if (alive) setErr(e instanceof Error ? e.message : (isZh ? '加载失败' : 'load failed')) })
}
load()
const id = setInterval(load, 5 * 60_000)
return () => { alive = false; clearInterval(id) }
}, [isZh])
if (err) {
return (
<div className="card" style={{ padding: 14, color: 'var(--down)', fontSize: 12 }}>
{`Funding snapshot failed — ${err}`}
</div>
)
}
if (!snap) {
return (
<div className="skeleton-card">
<div className="skeleton sk-line sk-w-3q" />
<div className="skeleton sk-title sk-w-half" />
<div className="skeleton sk-line sk-w-full" />
</div>
)
}
if (!snap.ok) {
return (
<div className="card" style={{ padding: 14, color: 'var(--ink-3)', fontSize: 12 }}>
{`Funding data unavailable — ${snap.error || 'unknown error'}`}
</div>
)
}
// Color the cumulative figure by direction + extremity.
const cum = snap.cum_30d_pct ?? 0
const thr = snap.extreme_threshold_pct ?? 3
const extreme = Math.abs(cum) >= thr
const cumColor = extreme ? (cum > 0 ? 'var(--down)' : 'var(--up)') : 'var(--ink)'
// Direction hint if extreme (longs paying = SHORT setup; shorts paying = LONG)
const directionHint = extreme
? (cum > 0
? 'Longs are crowded; reversal bias is SHORT.'
: 'Shorts are crowded; reversal bias is LONG.')
: 'Funding remains inside the normal range.'
return (
<div className="card" style={{ padding: 16, marginBottom: 12 }}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
flexWrap: 'wrap', gap: 12, marginBottom: 12,
}}>
<div style={{ fontSize: 14, fontWeight: 700 }}>
BTC perp funding · live
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)' }}>
{`${snap.cadence_hours}h cadence · ${snap.coverage_days}d coverage · Binance`}
</div>
</div>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 12, marginBottom: 12,
}}>
<StatBox
label="Latest cycle"
value={`${snap.latest_rate_pct?.toFixed(4)}%`}
/>
<StatBox
label="Last 24h avg"
value={`${snap.last_24h_avg_pct?.toFixed(4)}%`}
/>
<StatBox
label="30d cumulative"
value={`${cum >= 0 ? '+' : ''}${cum.toFixed(2)}%`}
color={cumColor}
sub={`extreme @ ±${thr}%`}
/>
<StatBox
label="Signal"
value={snap.signal_fired
? '🚨 FIRED'
: extreme
? '⏳ Watching'
: '✓ Quiet'}
color={snap.signal_fired ? 'var(--down)' : extreme ? 'var(--amber, #f59e0b)' : 'var(--up)'}
/>
</div>
<div style={{
padding: '8px 12px', borderRadius: 6, fontSize: 12,
background: extreme ? 'var(--down-soft, rgba(220,38,38,.08))' : 'var(--bg-sunk)',
color: extreme ? 'var(--down)' : 'var(--ink-3)',
marginBottom: 12,
}}>
{directionHint}
{snap.debug?.reason && (
<span style={{ color: 'var(--ink-4)', marginLeft: 8 }}>
({String(snap.debug.reason).replaceAll('_', ' ')})
</span>
)}
</div>
{snap.history && snap.history.length > 1 && (
<FundingSparkline
history={snap.history}
cadenceHours={snap.cadence_hours ?? 8}
extremeCumPct={thr}
isZh={isZh}
/>
)}
</div>
)
}
function StatBox({ label, value, color, sub }: {
label: string; value: string; color?: string; sub?: string
}) {
return (
<div style={{
padding: 10, background: 'var(--bg-sunk)', borderRadius: 6,
border: '1px solid var(--line)',
}}>
<div style={{ fontSize: 10, color: 'var(--ink-4)', textTransform: 'uppercase',
letterSpacing: '.06em', marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: color || 'var(--ink)',
fontVariantNumeric: 'tabular-nums' }}>{value}</div>
{sub && <div style={{ fontSize: 10, color: 'var(--ink-4)', marginTop: 2 }}>{sub}</div>}
</div>
)
}
// Tiny inline SVG sparkline of the last 7 days of funding cycles.
// `extremeCumPct` is the 30d cumulative threshold (e.g. 3.0). To project it as
// a per-cycle reference line we divide by the number of cycles in 30 days at
// the venue's cadence (24h × 30d / cadence_h). This is the per-cycle rate that
// — sustained for 30 days — would hit the extreme bucket.
function FundingSparkline({ history, cadenceHours, extremeCumPct, isZh }: {
history: { t: number; rate_pct: number }[]
cadenceHours: number
extremeCumPct: number
isZh: boolean
}) {
const W = 600, H = 90, PAD = 6
const cyclesIn30d = Math.max(1, (30 * 24) / Math.max(cadenceHours, 0.5))
const perCycleThr = extremeCumPct / cyclesIn30d // e.g. 3% / 90 ≈ 0.033%
const rates = history.map(h => h.rate_pct)
// Ensure threshold lines are always visible in the y-range
const minR = Math.min(...rates, -perCycleThr * 1.2)
const maxR = Math.max(...rates, perCycleThr * 1.2)
const span = maxR - minR || 1
const x = (i: number) => PAD + (i / (history.length - 1)) * (W - 2 * PAD)
const y = (r: number) => PAD + (1 - (r - minR) / span) * (H - 2 * PAD)
const zeroY = y(0)
const posThrY = y(perCycleThr)
const negThrY = y(-perCycleThr)
const path = history.map((h, i) => `${i === 0 ? 'M' : 'L'} ${x(i)} ${y(h.rate_pct)}`).join(' ')
const last = history[history.length - 1]
// Color the dot by whether the latest cycle is inside the danger band
const inDanger = Math.abs(last.rate_pct) >= perCycleThr
const dotColor = inDanger
? (last.rate_pct > 0 ? 'var(--down)' : 'var(--up)')
: 'var(--amber, #f59e0b)'
return (
<div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 10, color: 'var(--ink-4)', marginBottom: 4,
}}>
<span>{isZh ? '7 日资金费率(单周期 %' : '7-day funding (per-cycle %)'}</span>
<span>{isZh ? `危险区间:每周期 ±${perCycleThr.toFixed(3)}%` : `danger band: ±${perCycleThr.toFixed(3)}% / cycle`}</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: H, display: 'block' }}>
{/* danger zones — shaded above +thr and below -thr */}
<rect x={PAD} y={PAD} width={W - 2 * PAD} height={Math.max(0, posThrY - PAD)}
fill="var(--down)" opacity={0.07} />
<rect x={PAD} y={negThrY} width={W - 2 * PAD} height={Math.max(0, H - PAD - negThrY)}
fill="var(--up)" opacity={0.07} />
{/* zero line */}
<line x1={PAD} x2={W - PAD} y1={zeroY} y2={zeroY}
stroke="var(--line-2, var(--line))" strokeWidth={1} />
{/* positive / negative threshold lines */}
<line x1={PAD} x2={W - PAD} y1={posThrY} y2={posThrY}
stroke="var(--down)" strokeWidth={1} strokeDasharray="3 3" opacity={0.6} />
<line x1={PAD} x2={W - PAD} y1={negThrY} y2={negThrY}
stroke="var(--up)" strokeWidth={1} strokeDasharray="3 3" opacity={0.6} />
{/* funding curve */}
<path d={path} fill="none" stroke="var(--amber, #f59e0b)" strokeWidth={1.5} />
{/* current value dot */}
<circle cx={x(history.length - 1)} cy={y(last.rate_pct)} r={3.5} fill={dotColor} />
</svg>
</div>
)
}
function BtcSkeleton() {
return (
<div className="post-stream" style={{ marginTop: 8 }}>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="skeleton-card" style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', gap: 8 }}>
<div className="skeleton sk-line-sm" style={{ width: 80 }} />
<div className="skeleton sk-line-sm" style={{ width: 60 }} />
</div>
<div className="skeleton sk-title sk-w-3q" />
<div className="skeleton sk-line sk-w-full" />
<div className="skeleton sk-line sk-w-half" />
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<div className="skeleton sk-line-sm" style={{ width: 56 }} />
<div className="skeleton sk-line-sm" style={{ width: 80 }} />
</div>
</div>
))}
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { getFundingSnapshot, getPosts } from '@/lib/api'
import type { Metadata } from 'next'
import BtcPageClient from './BtcPageClient'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
export const revalidate = 30
interface BtcPageProps {
params: Promise<{ locale: string }>
}
export async function generateMetadata({
params,
}: BtcPageProps): Promise<Metadata> {
const { locale } = await params
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return {
title: isZh ? 'BTC 周期底部信号扫描器' : 'BTC Bottom Reversal Scanner',
description: isZh
? 'Trump Alpha 的比特币周期底部扫描器,使用 AHR999、200 周均线和 Pi Cycle Bottom 三项经典信号做 2-of-3 共振判断,识别低频、高确定性的 BTC 长线底部区域。'
: 'Bitcoin macro-bottom scanner using a 2-of-3 price confluence: AHR999 deep-value zone, price near the 200-week moving average, and Pi Cycle Bottom. Rare, high-conviction long-only signals.',
keywords: isZh
? [
'BTC 底部信号',
'比特币周期底部',
'AHR999',
'200 周均线',
'Pi Cycle Bottom',
'比特币反转信号',
'加密价格共振',
]
: [
'Bitcoin bottom signal',
'AHR999',
'200-week moving average',
'Pi Cycle Bottom',
'BTC cycle bottom',
'Bitcoin macro bottom',
'Bitcoin reversal signal',
'crypto price confluence',
],
openGraph: {
title: isZh ? 'BTC 周期底部信号扫描器 | Trump Alpha' : 'BTC Bottom Reversal Scanner | Trump Alpha',
description: isZh
? '用 AHR999、200 周均线和 Pi Cycle Bottom 识别比特币宏观底部。低频,但每次触发都服务于长周期反转。'
: 'Bitcoin macro-bottom detector: 2-of-3 confluence across AHR999, 200-week MA, and Pi Cycle Bottom. Rare, high-conviction long-only signals.',
},
alternates: {
canonical: `${siteUrl}/${locale}/btc`,
languages: {
en: `${siteUrl}/en/btc`,
'x-default': `${siteUrl}/en/btc`,
},
},
}
}
export default async function BtcPage() {
const [posts, fundingSnapshot] = await Promise.all([
getPosts(500, 1).catch(() => null),
getFundingSnapshot().catch(() => null),
])
return (
<BtcPageClient
initialPosts={posts}
initialFundingSnapshot={fundingSnapshot}
/>
)
}
+425
View File
@@ -0,0 +1,425 @@
import type { Metadata } from 'next'
import Link from 'next/link'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
interface CaseStudy {
id: string
date: string
source: string
title: string
summary: string
signal: 'LONG' | 'SHORT' | 'DIVERGENCE'
asset: string
priceImpact: string
impactWindow: string
detail: string
evidence: string
externalUrl?: string
}
type CaseStudiesCopy = {
title: string
description: string
keywords: string[]
ogTitle: string
ogDescription: string
heroTitle: string
heroSubtitle: string
intro: string
statAsset: string
statImpact: string
statWindow: string
evidenceLabel: string
footer: string
footerMethodology: string
footerGlossary: string
cases: CaseStudy[]
}
function getCopy(locale: string): CaseStudiesCopy {
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
if (isZh) {
return {
title: '案例研究 - Trump Alpha',
description:
'整理 Trump Truth Social 帖子如何影响比特币与加密市场的公开案例,包括战略储备公告、链上大额抢跑交易、风险偏好切换以及 KOL 言行偏离样本。',
keywords: [
'Trump 加密市场影响',
'Truth Social 比特币',
'Trump 帖子行情',
'加密市场案例研究',
'KOL 言行偏离案例',
'BTC 底部案例',
],
ogTitle: 'Case Studies | Trump Alpha',
ogDescription:
'记录过往真正推动市场的帖子、仓位和链上证据,解释这些信号为什么值得跟踪。',
heroTitle: '案例研究',
heroSubtitle: '把历史上真正推动市场的帖子、仓位和价格反应摊开来看',
intro:
'这不是收益展示页,而是证据页。每个案例都尽量回答三个问题:发生了什么、市场怎么动、为什么这件事支持某个信号引擎的存在。',
statAsset: '资产',
statImpact: '价格影响',
statWindow: '观察窗口',
evidenceLabel: '证据',
footer: '这些案例用于说明信号背后的历史依据,不构成前瞻性收益承诺。',
footerMethodology: '方法论',
footerGlossary: '术语表',
cases: [
{
id: 'strategic-reserve-2025',
date: '2025 年 3 月 2 日',
source: 'Trump Truth Social',
title: '美国“战略加密储备”表态',
summary: 'Trump 在 Truth Social 上确认美国战略加密储备将涵盖 BTC、ETH、SOL、XRP 和 ADA。',
signal: 'LONG',
asset: 'BTC / ETH',
priceImpact: 'BTC +8.2%ETH +10%+',
impactWindow: '24 小时',
detail:
'这条帖子之所以重要,不只是因为点名了多个资产,而是因为它把“政府政策支持”从模糊口风变成了明确承诺。Trump Alpha 的语义引擎会把这种带有具体资产清单和国家层面措辞的内容识别为高置信度 LONG 事件。',
evidence:
'CNBC 和 Al Jazeera 均报道了该消息后的市场反应。链上和交易所数据也出现了明显的动量资金流入。',
externalUrl:
'https://www.cnbc.com/2025/03/02/trump-announces-strategic-crypto-reserve-including-bitcoin-solana-xrp-and-more.html',
},
{
id: 'whale-6m-perp',
date: '2025 年 3 月',
source: '链上 / Truth Social',
title: '战略储备帖子前后出现 680 万美元抢跑收益',
summary:
'一只匿名地址在战略储备帖子发布前后快速建立 50 倍 BTC/ETH 杠杆仓位,并在当天平仓,获利约 680 万美元。',
signal: 'LONG',
asset: 'BTC',
priceImpact: '+680 万美元',
impactWindow: '当日',
detail:
'这个案例说明了两件事:第一,Trump 帖子确实能形成足够大的短线价格冲击;第二,在这种事件中,反应速度本身就是边际优势。无论这笔交易是抢跑还是极快的自动化反应,它都证明了“帖子 -> 交易 -> 盈亏”链条是真实存在的。',
evidence:
'Raw Story / NewsBreak 对这笔交易做了记录,TIME 也提到相关监管层面的质疑与讨论。',
externalUrl:
'https://www.newsbreak.com/raw-story-2096750/4286876592634-that-s-the-maga-playbook-crypto-trade-made-moments-before-trump-post-raises-eyebrows',
},
{
id: 'tariff-china-2025',
date: '2025 年 4 月',
source: 'Trump Truth Social',
title: '关税升级帖子引发风险偏好下移',
summary: 'Trump 发文谈及对中国加征大规模关税,这类表态通常被市场解读为短线 risk-off。',
signal: 'SHORT',
asset: 'BTC',
priceImpact: 'BTC -4.1%',
impactWindow: '4 小时',
detail:
'并不是每条 Trump 帖子都偏利多。带有贸易摩擦、关税升级、监管收紧或避险语义的内容,往往会压缩风险资产偏好。这个案例支持了信号系统中 SHORT 方向存在的必要性。',
evidence:
'CoinDesk 曾整理过多次 Trump 发言影响 BTC 的案例,这次关税语境下的风险偏好回落与其历史模式一致。',
externalUrl:
'https://www.coindesk.com/markets/2026/04/20/five-times-president-trump-made-a-statement-that-moved-bitcoin-and-why-it-might-happen-again-this-week',
},
{
id: 'kol-divergence-example',
date: '示意型复合案例',
source: 'KOL 文章 + 链上地址',
title: '公开看多 ETH,但钱包在减仓',
summary:
'某位被追踪的 KOL 发布看多 ETH 的长文,随后 5 天内其钱包减少约 18 万美元 ETH 仓位,构成典型言行偏离。',
signal: 'DIVERGENCE',
asset: 'ETH',
priceImpact: 'N/A',
impactWindow: '±7 天',
detail:
'这个案例不是在讲单条帖子多会拉盘,而是在讲“公开叙事”和“真实仓位”可能不是一回事。对于研究型用户,这类偏离通常比单纯看多或看空更有信息增量。',
evidence:
'言行偏离的判定逻辑来自平台内部交叉信号系统,方法在 Methodology 页面有完整说明。',
},
{
id: 'btc-bottom-nov-2022',
date: '2022 年 11 月',
source: '历史 BTC 周期底部参考',
title: 'FTX 崩盘后的 1.55 万美元区域',
summary:
'FTX 事件后 BTC 跌入约 1.55 万美元区域,随后在 18 个月内回升至 6.9 万美元附近。这是 BTC 周期底部系统试图捕捉的环境类型。',
signal: 'LONG',
asset: 'BTC',
priceImpact: '+345%',
impactWindow: '18 个月',
detail:
'当前线上 BTC 系统并不是去回放旧版链上触发器,而是用 AHR999、200 周均线和 Pi Cycle Bottom 去逼近同一种市场结构:深度杀估值、长期趋势支撑和长期情绪出清同时出现。',
evidence:
'公开历史行情和长期图表都把 FTX 洗盘区间视为该轮熊市的底部核心区域。',
},
],
}
}
return {
title: 'Case Studies — Documented Trump Posts That Moved Crypto',
description:
'Documented cases where Trump Truth Social posts, KOL positioning, or macro-bottom conditions moved Bitcoin and crypto markets. Includes price impact, timing, and evidence.',
keywords: [
'Trump crypto market impact',
'Trump Truth Social Bitcoin',
'crypto market moving events',
'Trump BTC trade case study',
'KOL divergence example',
'Bitcoin macro bottom case study',
],
ogTitle: 'Case Studies | Trump Alpha',
ogDescription:
'Historical cases that show why the Trump, BTC, and KOL engines exist: posts, wallet behavior, price reaction, and evidence.',
heroTitle: 'Case Studies',
heroSubtitle: 'Real posts, real positioning, and real market reactions worth studying',
intro:
'This is not a performance-claims page. It is an evidence page. Each case is here to answer three questions: what happened, how the market reacted, and why that event supports the existence of a specific signal engine.',
statAsset: 'Asset',
statImpact: 'Price impact',
statWindow: 'Window',
evidenceLabel: 'Evidence',
footer: 'These cases illustrate the historical basis of the signal stack. They are not forward-looking return promises.',
footerMethodology: 'Methodology',
footerGlossary: 'Glossary',
cases: [
{
id: 'strategic-reserve-2025',
date: 'March 2, 2025',
source: 'Trump Truth Social',
title: 'Strategic Crypto Reserve announcement',
summary: 'Trump confirmed a U.S. Strategic Crypto Reserve including BTC, ETH, SOL, XRP, and ADA.',
signal: 'LONG',
asset: 'BTC / ETH',
priceImpact: 'BTC +8.2%, ETH +10%+',
impactWindow: '24 hours',
detail:
'This case mattered because the post moved from vague political tone into explicit asset-level policy language. A signal engine should classify that as a high-conviction LONG event, because it names specific assets and ties them to a state-level commitment.',
evidence:
'CNBC and Al Jazeera both documented the market response, and exchange plus on-chain data showed clear momentum-driven inflows after the post.',
externalUrl:
'https://www.cnbc.com/2025/03/02/trump-announces-strategic-crypto-reserve-including-bitcoin-solana-xrp-and-more.html',
},
{
id: 'whale-6m-perp',
date: 'March 2025',
source: 'On-chain / Truth Social',
title: '$6.8M pre-positioning profit around the reserve post',
summary:
'An anonymous address built a large leveraged BTC/ETH position around the reserve announcement and reportedly closed it the same day for roughly $6.8M profit.',
signal: 'LONG',
asset: 'BTC',
priceImpact: '+$6.8M',
impactWindow: 'Same day',
detail:
'This case highlights two facts at once. First, Trump-linked posts can create enough directional force to matter. Second, speed itself is edge in event-driven trading. Whether this was advance positioning or ultra-fast automation, it validates the post-to-trade-to-PnL chain.',
evidence:
'The trade was documented by Raw Story / NewsBreak, and TIME later referenced the political and regulatory scrutiny around the episode.',
externalUrl:
'https://www.newsbreak.com/raw-story-2096750/4286876592634-that-s-the-maga-playbook-crypto-trade-made-moments-before-trump-post-raises-eyebrows',
},
{
id: 'tariff-china-2025',
date: 'April 2025',
source: 'Trump Truth Social',
title: 'Tariff escalation post triggered a risk-off move',
summary: 'A tariff-escalation post created a short-term macro risk-off setup rather than a bullish crypto reaction.',
signal: 'SHORT',
asset: 'BTC',
priceImpact: 'BTC -4.1%',
impactWindow: '4 hours',
detail:
'Not every Trump post is bullish for crypto. Posts framed around trade conflict, tariff escalation, tighter regulation, or macro uncertainty can compress risk appetite quickly. This case supports the need for a real SHORT branch in the signal engine.',
evidence:
'CoinDesk has documented multiple instances of Trump statements moving BTC. This post fits that broader historical pattern of immediate sentiment repricing.',
externalUrl:
'https://www.coindesk.com/markets/2026/04/20/five-times-president-trump-made-a-statement-that-moved-bitcoin-and-why-it-might-happen-again-this-week',
},
{
id: 'kol-divergence-example',
date: 'Illustrative composite',
source: 'KOL post + on-chain wallet',
title: 'Publicly bullish on ETH, privately reducing ETH',
summary:
'A tracked KOL published a bullish ETH thesis, then reduced roughly $180k of ETH exposure within five days. That is classic talks-vs-trades divergence.',
signal: 'DIVERGENCE',
asset: 'ETH',
priceImpact: 'N/A',
impactWindow: '±7 days',
detail:
'This example is not about a single post moving price. It is about the information gap between public narrative and real positioning. For research-heavy users, that gap is often more valuable than a simple bullish or bearish statement.',
evidence:
'The divergence classification comes from the platforms cross-signal logic, which is documented in full on the Methodology page.',
},
{
id: 'btc-bottom-nov-2022',
date: 'November 2022',
source: 'Historical BTC macro-bottom reference',
title: 'The $15.5k FTX washout zone',
summary:
'After the FTX collapse, BTC entered the $15.5k region and later recovered toward $69k within roughly 18 months. This is the kind of regime the BTC macro-bottom engine is built to identify.',
signal: 'LONG',
asset: 'BTC',
priceImpact: '+345%',
impactWindow: '18 months',
detail:
'The current live BTC engine is not trying to replay an old premium-data model. It is trying to locate the same market structure with AHR999, the 200-week moving average, and Pi Cycle Bottom: deep value, long-cycle support, and emotional washout lining up together.',
evidence:
'Public price archives and long-range market charts consistently mark the FTX washout zone as the core bottom region of that bear market.',
},
],
}
}
export function generateMetadata({
params: { locale },
}: {
params: { locale: string }
}): Metadata {
const copy = getCopy(locale)
return {
title: copy.title,
description: copy.description,
keywords: copy.keywords,
openGraph: {
title: copy.ogTitle,
description: copy.ogDescription,
},
alternates: {
canonical: `${siteUrl}/${locale}/case-studies`,
languages: {
en: `${siteUrl}/en/case-studies`,
'x-default': `${siteUrl}/en/case-studies`,
},
},
}
}
const SIGNAL_COLOR: Record<string, string> = {
LONG: '#22c55e',
SHORT: '#ef4444',
DIVERGENCE: '#f5a524',
}
export default function CaseStudiesPage({ params: { locale } }: { params: { locale: string } }) {
const copy = getCopy(locale)
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return (
<div className="page" style={{ maxWidth: 780 }}>
<div className="page-head">
<div>
<h1 className="page-title">{copy.heroTitle}</h1>
<p className="page-sub">{copy.heroSubtitle}</p>
</div>
</div>
<p style={{
fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.7,
marginBottom: 24, fontStyle: 'italic',
}}>
{copy.intro}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{copy.cases.map((c) => (
<article
key={c.id}
className="card"
style={{ padding: '24px 28px' }}
itemScope
itemType="https://schema.org/Article"
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
<span style={{
fontSize: 10, fontWeight: 700, letterSpacing: '0.07em',
textTransform: 'uppercase', fontFamily: 'monospace',
padding: '4px 10px', borderRadius: 4, flexShrink: 0,
background: `color-mix(in srgb, ${SIGNAL_COLOR[c.signal]} 14%, transparent)`,
color: SIGNAL_COLOR[c.signal],
border: `1px solid color-mix(in srgb, ${SIGNAL_COLOR[c.signal]} 30%, transparent)`,
}}>
{c.signal}
</span>
<span style={{ fontSize: 12, color: 'var(--ink-4)', fontFamily: 'monospace', flexShrink: 0 }}>
{c.date}
</span>
<span style={{
fontSize: 11, color: 'var(--ink-4)', padding: '4px 8px',
background: 'var(--bg-sunk)', borderRadius: 4, flexShrink: 0,
}}>
{c.source}
</span>
</div>
<h2
itemProp="headline"
style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink)', margin: '0 0 8px', lineHeight: 1.3 }}
>
{c.title}
</h2>
<p style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.6, margin: '0 0 16px' }}>
{c.summary}
</p>
<div style={{
display: 'flex', gap: 0, marginBottom: 16, flexWrap: 'wrap',
border: '1px solid var(--line)', borderRadius: 8, overflow: 'hidden',
}}>
{[
{ k: copy.statAsset, v: c.asset },
{ k: copy.statImpact, v: c.priceImpact },
{ k: copy.statWindow, v: c.impactWindow },
].map(({ k, v }, i) => (
<div key={k} style={{
flex: '1 1 120px', padding: '10px 16px',
borderRight: i < 2 ? '1px solid var(--line)' : 'none',
background: 'var(--bg-sunk)',
}}>
<div style={{ fontSize: 10, color: 'var(--ink-4)', textTransform: 'uppercase', letterSpacing: '0.06em', fontFamily: 'monospace', marginBottom: 4 }}>{k}</div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--ink)', fontFamily: 'monospace' }}>{v}</div>
</div>
))}
</div>
<p itemProp="description" style={{ fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.7, margin: '0 0 12px' }}>
{c.detail}
</p>
<p style={{
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.65,
margin: 0, paddingLeft: 12,
borderLeft: '2px solid var(--line-2)',
}}>
<strong>{copy.evidenceLabel}:</strong> {c.evidence}
{c.externalUrl && (
<> {' '}
<a
href={c.externalUrl}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--amber)', textDecoration: 'none' }}
>
{isZh ? '来源 ↗' : 'Source ↗'}
</a>
</>
)}
</p>
</article>
))}
</div>
<div className="card" style={{ padding: '20px 24px', marginTop: 12 }}>
<p style={{ fontSize: 13, color: 'var(--ink-4)', lineHeight: 1.6, margin: 0 }}>
{copy.footer}{' '}
{isZh ? '另见:' : 'See also:'}{' '}
<Link href={`/${locale}/methodology`} style={{ color: 'var(--amber)' }}>{copy.footerMethodology}</Link>
{' '}·{' '}
<Link href={`/${locale}/glossary`} style={{ color: 'var(--amber)' }}>{copy.footerGlossary}</Link>.
</p>
</div>
</div>
)
}
+15 -10
View File
@@ -1,10 +1,15 @@
export default function ContactPage() {
import { getLocale } from 'next-intl/server'
export default async function ContactPage() {
const locale = await getLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return (
<div className="page" style={{ maxWidth: 560 }}>
<div className="page-head">
<div>
<h1 className="page-title">Contact Us</h1>
<p className="page-sub">Questions, feedback, or support requests.</p>
<h1 className="page-title">{isZh ? '联系我们' : 'Contact Us'}</h1>
<p className="page-sub">{isZh ? '问题、反馈或支持请求都可以从这里发来。' : 'Questions, feedback, or support requests.'}</p>
</div>
</div>
@@ -15,30 +20,30 @@ export default function ContactPage() {
encType="text/plain"
>
<div className="field" style={{ marginBottom: 16 }}>
<label htmlFor="subject">Subject</label>
<input id="subject" name="subject" type="text" placeholder="e.g. Bot not executing trades" />
<label htmlFor="subject">{isZh ? '主题' : 'Subject'}</label>
<input id="subject" name="subject" type="text" placeholder={isZh ? '例如:机器人没有执行交易' : 'e.g. Bot not executing trades'} />
</div>
<div className="field" style={{ marginBottom: 24 }}>
<label htmlFor="body">Message</label>
<label htmlFor="body">{isZh ? '内容' : 'Message'}</label>
<textarea
id="body"
name="body"
rows={6}
placeholder="Describe your issue or question in detail…"
placeholder={isZh ? '尽量详细描述你的问题或需求…' : 'Describe your issue or question in detail…'}
style={{ width: '100%', resize: 'vertical' }}
/>
</div>
<button type="submit" className="btn amber lg">
Open in mail client
{isZh ? '用邮件客户端打开 →' : 'Open in mail client →'}
</button>
</form>
<div style={{ marginTop: 28, paddingTop: 20, borderTop: '1px solid var(--line)', fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.6 }}>
<div style={{ marginBottom: 6 }}>You can also reach us directly:</div>
<div style={{ marginBottom: 6 }}>{isZh ? '也可以直接发邮件给我们:' : 'You can also reach us directly:'}</div>
<div>
<a href="mailto:support@bitnews.day" style={{ color: 'var(--amber)' }}>support@bitnews.day</a>
</div>
<div style={{ marginTop: 8 }}>Response time: typically within 24 hours.</div>
<div style={{ marginTop: 8 }}>{isZh ? '通常会在 24 小时内回复。' : 'Response time: typically within 24 hours.'}</div>
</div>
</div>
</div>
+45 -2
View File
@@ -48,8 +48,8 @@
--shadow-2: 0 4px 16px rgba(20, 18, 14, 0.06), 0 1px 2px rgba(20, 18, 14, 0.03);
--shadow-3: 0 12px 40px rgba(20, 18, 14, 0.08), 0 2px 4px rgba(20, 18, 14, 0.04);
--sans: 'Geist', ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
--mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
--sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
--mono: var(--font-geist-mono), ui-monospace, 'SF Mono', Menlo, monospace;
}
html[data-theme="dark"] {
@@ -953,6 +953,12 @@ html[data-theme="dark"] .ai-reasoning-card {
}
.src-ico.x { background: #111; color: #fff; }
.src-ico.truth { background: oklch(94% 0.05 25); color: oklch(45% 0.2 25); }
/* Non-Trump sources — distinct hues so the feed reads at a glance. */
.src-ico.breakout { background: oklch(94% 0.08 200); color: oklch(40% 0.15 220); }
.src-ico.reversal { background: oklch(94% 0.08 300); color: oklch(40% 0.15 300); }
.src-ico.whale { background: oklch(94% 0.08 150); color: oklch(40% 0.15 150); font-size: 13px; }
.src-ico.manual { background: oklch(94% 0.05 60); color: oklch(45% 0.15 60); font-size: 13px; }
.src-ico.external { background: var(--bg-sunk); color: var(--ink-2); }
.post-body .meta {
display: flex;
align-items: center;
@@ -1347,3 +1353,40 @@ html, body { max-width: 100%; overflow-x: hidden; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 999px; border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: oklch(80% 0.01 85); }
/* ── Skeleton / shimmer loading ─────────────────────────────────────────────
Use .skeleton on any block element to get a shimmer placeholder.
Combine with explicit width/height or min-height to shape the placeholder.
.skeleton-card wraps a card-shaped block. */
@keyframes shimmer {
0% { background-position: -600px 0; }
100% { background-position: 600px 0; }
}
.skeleton {
border-radius: 6px;
background: linear-gradient(
90deg,
var(--bg-sunk) 25%,
color-mix(in srgb, var(--bg-sunk) 60%, var(--line)) 50%,
var(--bg-sunk) 75%
);
background-size: 1200px 100%;
animation: shimmer 1.4s ease-in-out infinite;
}
.skeleton-card {
border-radius: var(--r);
border: 1px solid var(--line);
padding: 20px;
background: var(--bg-card);
display: flex;
flex-direction: column;
gap: 10px;
}
/* Convenience shorthands */
.sk-line { height: 14px; }
.sk-line-sm { height: 11px; }
.sk-title { height: 20px; }
.sk-w-full { width: 100%; }
.sk-w-3q { width: 75%; }
.sk-w-half { width: 50%; }
.sk-w-q { width: 25%; }
+423
View File
@@ -0,0 +1,423 @@
import type { Metadata } from 'next'
import Link from 'next/link'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
type GlossaryTerm = {
term: string
abbr?: string
category: string
definition: string
extra?: string
}
type GlossaryCopy = {
title: string
description: string
keywords: string[]
ogTitle: string
ogDescription: string
heroTitle: string
heroSubtitle: string
intro: string
terms: GlossaryTerm[]
footerLead: string
footerContact: string
footerMethodology: string
footerCaseStudies: string
}
function getCopy(locale: string): GlossaryCopy {
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
if (isZh) {
return {
title: '加密信号术语表 - Trump Alpha',
description:
'系统化定义 Trump Alpha 使用的核心术语,包括 AHR999、Pi Cycle Bottom、200 周均线、KOL、言行偏离、资金费率、Conviction Score、逐仓等。',
keywords: [
'AHR999 定义',
'Pi Cycle Bottom 是什么',
'200周均线',
'KOL 是什么',
'言行偏离',
'资金费率',
'逐仓',
'加密信号术语表',
],
ogTitle: 'Crypto Signals Glossary | Trump Alpha',
ogDescription:
'Trump Alpha 核心术语的清晰定义,适合搜索摘录、AI 引用和快速查阅。',
heroTitle: '术语表',
heroSubtitle: '把页面里所有关键实体和专业词说清楚',
intro:
'这份术语表服务两个目标:第一,帮助用户快速理解平台内的指标和信号;第二,让搜索引擎和 LLM 能稳定识别这些实体之间的关系。每个词条都尽量给出定义、上下文和在 Trump Alpha 里的具体用法。',
terms: [
{
term: 'AHR999',
category: '宏观底部指标',
definition:
'AHR999 是中文加密圈常用的比特币估值指标,通常结合长期趋势和成本锚来判断 BTC 是否进入深度价值区。在 Trump Alpha 里,AHR999 低于 0.45 被视为一个经典底部信号。',
extra: '它是当前 BTC 2-of-3 周期底部扫描器中的三大输入之一。',
},
{
term: 'Pi Cycle Bottom',
category: '宏观底部指标',
definition:
'Pi Cycle Bottom 是一种基于长周期均线关系的比特币底部判定方法。Trump Alpha 使用的条件是 150 日 EMA 低于 471 日 SMA 乘以 0.745。',
extra: '它也是 BTC 周期底部扫描器的三项核心输入之一。',
},
{
term: '200-week Moving Average',
category: '宏观底部指标',
definition:
'200 周均线是比特币最常见的超长周期趋势锚之一。历史上,多轮熊市低点都出现在这个区域附近。Trump Alpha 将价格接近或跌破该均线视为底部共振条件的一部分。',
},
{
term: 'KOL',
abbr: 'Key Opinion Leader',
category: '平台术语',
definition:
'在加密语境里,KOL 指的是能持续影响市场叙事或零售情绪的分析师、基金经理、内容创作者或节目主持人。Trump Alpha 跟踪的是带有长期内容产出的 KOL,而不是只发碎片推文的账号。',
},
{
term: 'Talks-vs-Trades Divergence',
category: '平台术语',
definition:
'言行偏离指 KOL 公开表达的方向与链上钱包实际操作方向相反。例如公开看多 ETH,但钱包在同一周内减仓 ETH。Trump Alpha 把链上动作视作更高优先级的真实信号。',
extra: '这是平台里信息密度最高的信号之一,因为它把公开叙事和真实仓位拆开来看。',
},
{
term: 'Conviction Score',
category: '平台术语',
definition:
'Conviction Score 是 AI 给每条 KOL 观点分配的强度分,范围通常在 0.0 到 1.0。分数越高,说明作者表达越明确、重复度越高,且更可能带有仓位、时间或执行语气。',
},
{
term: 'Funding Rate',
category: '衍生品术语',
definition:
'资金费率是永续合约用来锚定现货价格的周期性支付机制。多头付空头代表市场做多拥挤,空头付多头则代表市场做空拥挤。极端 funding 往往出现在清算前夜。',
},
{
term: 'Isolated Margin',
category: '交易术语',
definition:
'逐仓模式意味着每笔仓位只使用自己的保证金,不会和账户内其他持仓共用风险池。即使某笔交易被强平,也不会拖累其他持仓。',
extra: 'Trump Alpha 的自动执行层默认按逐仓方式工作。',
},
{
term: 'TP / SL',
abbr: 'Take-Profit / Stop-Loss',
category: '交易术语',
definition:
'TP 和 SL 分别表示止盈和止损条件单。Trump Alpha 的自动交易在开仓时就会挂好,而不是事后补挂。',
},
{
term: 'UTXO',
abbr: 'Unspent Transaction Output',
category: '比特币术语',
definition:
'UTXO 是比特币网络中的基础记账单元。虽然 Trump Alpha 当前线上 BTC 信号不再直接使用 UTXO 花费行为,但理解 UTXO 仍有助于读懂很多经典链上研究。',
},
{
term: 'Perpetual Future',
abbr: 'Perp',
category: '衍生品术语',
definition:
'Perp 是没有到期日的永续合约。它依靠资金费率和标记价格机制尽量贴近现货。Hyperliquid 就是提供 perp 交易的去中心化交易平台之一。',
},
{
term: 'Substack Signal',
category: '平台术语',
definition:
'Substack Signal 指从 KOL 的长文订阅、博客或深度文章中提取出的资产观点。相比短推文,这类内容通常更完整,因此更适合做高质量的观点抽取。',
},
{
term: 'Capitulation',
category: '市场术语',
definition:
'Capitulation 指持有者在持续亏损后集中放弃、被迫卖出的一段市场阶段。它往往出现在熊市尾声,也是 BTC 底部扫描器最希望捕捉的环境之一。',
},
{
term: 'EMA',
abbr: 'Exponential Moving Average',
category: '技术术语',
definition:
'EMA 是指数移动平均线,对最近价格赋予更高权重,因此比简单均线更敏感。Trump Alpha 在 Pi Cycle Bottom 条件中使用 150 日 EMA。',
},
],
footerLead: '缺少某个词条?',
footerContact: '联系我们',
footerMethodology: '方法论',
footerCaseStudies: '案例研究',
}
}
return {
title: 'Crypto Signals Glossary — AHR999, Pi Cycle Bottom, KOL Divergence',
description:
'Definitions of every core term used in Trump Alpha, including AHR999, Pi Cycle Bottom, 200-week moving average, KOL, talks-vs-trades divergence, funding rate, conviction score, and isolated margin.',
keywords: [
'AHR999 definition',
'Pi Cycle Bottom explained',
'200-week moving average Bitcoin',
'what is KOL crypto',
'talks vs trades definition',
'funding rate crypto',
'isolated margin perpetual',
'crypto signal glossary',
],
ogTitle: 'Crypto Signals Glossary | Trump Alpha',
ogDescription:
'Clear definitions of Trump Alphas core terms, optimized for search, AI retrieval, and fast reference.',
heroTitle: 'Glossary',
heroSubtitle: 'The precise meaning of every key entity and term on the platform',
intro:
'This glossary serves two jobs. First, it helps users understand the metrics and signal families inside Trump Alpha. Second, it gives search engines and LLMs a stable, retrieval-friendly map of the entities that appear across the product.',
terms: [
{
term: 'AHR999',
category: 'Macro-bottom metric',
definition:
'AHR999 is a Bitcoin valuation indicator popular in Chinese crypto research. It combines long-term trend and cost-basis style anchors to estimate whether BTC is trading in a deep-value regime. In Trump Alpha, AHR999 below 0.45 counts as one classic bottom signal.',
extra: 'It is one of the three inputs in the live BTC 2-of-3 macro-bottom scanner.',
},
{
term: 'Pi Cycle Bottom',
category: 'Macro-bottom metric',
definition:
'Pi Cycle Bottom is a Bitcoin bottom framework based on long-cycle moving-average relationships. In Trump Alpha, the condition is satisfied when the 150-day EMA falls below the 471-day SMA multiplied by 0.745.',
extra: 'It is one of the three core inputs in the BTC macro-bottom scanner.',
},
{
term: '200-week Moving Average',
category: 'Macro-bottom metric',
definition:
'The 200-week moving average is one of Bitcoins most widely watched long-term trend anchors. Many historical bear-market lows formed near this zone. Trump Alpha treats price proximity to the 200-week MA as part of BTC bottom confluence.',
},
{
term: 'KOL',
abbr: 'Key Opinion Leader',
category: 'Platform term',
definition:
'In crypto, a KOL is an analyst, fund manager, host, or creator whose public views consistently influence market narrative or retail positioning. Trump Alpha emphasizes long-form KOL sources, not only short social fragments.',
},
{
term: 'Talks-vs-Trades Divergence',
category: 'Platform term',
definition:
'Talks-vs-trades divergence happens when a KOLs public stance and wallet behavior point in opposite directions on the same asset. Example: a KOL publishes a bullish ETH thesis while their wallet reduces ETH exposure that same week. Trump Alpha treats the wallet move as the higher-priority truth signal.',
extra: 'This is one of the platforms highest-information signals because it separates narrative from actual positioning.',
},
{
term: 'Conviction Score',
category: 'Platform term',
definition:
'Conviction Score is the AI-generated strength score assigned to each extracted KOL view, typically ranging from 0.0 to 1.0. Higher values imply clearer language, stronger commitment, and better evidence of timing or sizing intent.',
},
{
term: 'Funding Rate',
category: 'Derivatives term',
definition:
'Funding rate is the periodic payment mechanism used by perpetual futures to keep contract pricing anchored to spot. If longs pay shorts, the market is long-crowded. If shorts pay longs, the market is short-crowded. Extreme readings often precede liquidation-driven reversals.',
},
{
term: 'Isolated Margin',
category: 'Trading term',
definition:
'Isolated margin means each trade uses its own dedicated margin rather than sharing risk with the rest of the account. If one position is liquidated, it does not automatically cascade into other positions.',
extra: 'Trump Alphas execution layer is designed around isolated margin.',
},
{
term: 'TP / SL',
abbr: 'Take-Profit / Stop-Loss',
category: 'Trading term',
definition:
'TP and SL are conditional exit orders for profit-taking and loss control. Trump Alphas auto-trader attaches both at entry rather than after the position is already open.',
},
{
term: 'UTXO',
abbr: 'Unspent Transaction Output',
category: 'Bitcoin term',
definition:
'UTXO is the fundamental accounting unit of the Bitcoin network. Trump Alphas live BTC signal no longer relies directly on UTXO-spend behavior, but the concept still matters for interpreting classic on-chain research.',
},
{
term: 'Perpetual Future',
abbr: 'Perp',
category: 'Derivatives term',
definition:
'A perp is a futures contract with no expiry date. It stays open until the trader closes it or gets liquidated, while funding rate and mark-price mechanisms help keep it close to spot. Hyperliquid is one example of a perp venue.',
},
{
term: 'Substack Signal',
category: 'Platform term',
definition:
'A Substack signal is an extracted asset view taken from a KOLs long-form essay, newsletter, or blog post. These sources usually contain fuller reasoning than short posts and therefore produce more retrieval-friendly signal data.',
},
{
term: 'Capitulation',
category: 'Market term',
definition:
'Capitulation is the stage of a drawdown when holders finally give up and sell into weakness. It often appears near the end of a bear market and is one of the environments the BTC bottom scanner is designed to detect.',
},
{
term: 'EMA',
abbr: 'Exponential Moving Average',
category: 'Technical term',
definition:
'EMA is a moving average that gives more weight to recent price data than older data. Trump Alpha uses the 150-day EMA inside the Pi Cycle Bottom condition.',
},
],
footerLead: 'Missing a term?',
footerContact: 'Contact us',
footerMethodology: 'Methodology',
footerCaseStudies: 'Case Studies',
}
}
export function generateMetadata({
params: { locale },
}: {
params: { locale: string }
}): Metadata {
const copy = getCopy(locale)
return {
title: copy.title,
description: copy.description,
keywords: copy.keywords,
openGraph: {
title: copy.ogTitle,
description: copy.ogDescription,
},
alternates: {
canonical: `${siteUrl}/${locale}/glossary`,
languages: {
en: `${siteUrl}/en/glossary`,
'x-default': `${siteUrl}/en/glossary`,
},
},
}
}
const CATEGORY_COLOR: Record<string, string> = {
'Macro-bottom metric': '#a78bfa',
'宏观底部指标': '#a78bfa',
'Platform term': '#f5a524',
'平台术语': '#f5a524',
'Trading term': '#22c55e',
'交易术语': '#22c55e',
'Derivatives term': '#60a5fa',
'衍生品术语': '#60a5fa',
'Bitcoin term': '#fb923c',
'比特币术语': '#fb923c',
'Market term': '#f87171',
'市场术语': '#f87171',
'Technical term': '#94a3b8',
'技术术语': '#94a3b8',
}
export default function GlossaryPage({ params: { locale } }: { params: { locale: string } }) {
const copy = getCopy(locale)
const categories = Array.from(new Set(copy.terms.map(t => t.category)))
return (
<div className="page" style={{ maxWidth: 760 }}>
<div className="page-head">
<div>
<h1 className="page-title">{copy.heroTitle}</h1>
<p className="page-sub">{copy.heroSubtitle}</p>
</div>
</div>
<p style={{
fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.7,
marginBottom: 20, fontStyle: 'italic',
}}>
{copy.intro}
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 24 }}>
{categories.map(cat => (
<span key={cat} style={{
fontSize: 11, fontWeight: 700, letterSpacing: '0.06em',
textTransform: 'uppercase', fontFamily: 'monospace',
padding: '4px 10px', borderRadius: 999,
background: `color-mix(in srgb, ${CATEGORY_COLOR[cat] ?? '#888'} 12%, transparent)`,
color: CATEGORY_COLOR[cat] ?? '#888',
border: `1px solid color-mix(in srgb, ${CATEGORY_COLOR[cat] ?? '#888'} 25%, transparent)`,
}}>
{cat}
</span>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{copy.terms.map((term) => (
<div
key={term.term}
id={term.term.toLowerCase().replace(/[^a-z0-9]+/g, '-')}
className="card"
style={{ padding: '20px 24px' }}
itemScope
itemType="https://schema.org/DefinedTerm"
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
<h2
itemProp="name"
style={{ fontSize: 16, fontWeight: 700, color: 'var(--ink)', margin: 0, flex: '1 1 auto' }}
>
{term.term}
</h2>
<span style={{
fontSize: 10, fontWeight: 700, letterSpacing: '0.07em',
textTransform: 'uppercase', fontFamily: 'monospace',
padding: '3px 8px', borderRadius: 4, flexShrink: 0,
background: `color-mix(in srgb, ${CATEGORY_COLOR[term.category] ?? '#888'} 12%, transparent)`,
color: CATEGORY_COLOR[term.category] ?? '#888',
}}>
{term.category}
</span>
</div>
{term.abbr && (
<p style={{ fontSize: 12, color: 'var(--ink-4)', margin: '4px 0 0', fontStyle: 'italic' }}>
{term.abbr}
</p>
)}
<p
itemProp="description"
style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.7, margin: '10px 0 0' }}
>
{term.definition}
</p>
{term.extra && (
<p style={{
fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65,
margin: '8px 0 0', paddingLeft: 12,
borderLeft: `2px solid ${CATEGORY_COLOR[term.category] ?? 'var(--amber)'}`,
}}>
{term.extra}
</p>
)}
</div>
))}
</div>
<div className="card" style={{ padding: '20px 24px', marginTop: 12 }}>
<p style={{ fontSize: 13, color: 'var(--ink-4)', lineHeight: 1.6, margin: 0 }}>
{copy.footerLead}{' '}
<Link href={`/${locale}/contact`} style={{ color: 'var(--amber)' }}>{copy.footerContact}</Link>.
{' '}{locale === 'zh' ? '另见:' : 'See also:'}{' '}
<Link href={`/${locale}/methodology`} style={{ color: 'var(--amber)' }}>{copy.footerMethodology}</Link>
{' '}·{' '}
<Link href={`/${locale}/case-studies`} style={{ color: 'var(--amber)' }}>{copy.footerCaseStudies}</Link>.
</p>
</div>
</div>
)
}
+966
View File
@@ -0,0 +1,966 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { useLocale } from 'next-intl'
import type {
KolPostSummary, KolPostDetail, KolTicker,
KolDigest, KolDigestTicker, KolHoldingChange, KolDivergence,
} from '@/types'
import { getKolPosts, getKolPost, getKolDigest, getKolChanges, getKolDivergence } from '@/lib/api'
import { swrFetch } from '@/lib/cache'
/**
* KOL feed — independent module. Lists analyzed posts from tracked KOLs
* (Substack today, Twitter later). Click a row to open the full post body
* + structured ticker calls inline.
*/
const ACTION_COLOR: Record<KolTicker['action'], string> = {
buy: '#16a34a',
bullish: '#16a34a',
sell: '#dc2626',
bearish: '#dc2626',
mention: '#9ca3af',
}
const WINDOW_OPTIONS = [1, 7, 30, 90] as const
function actionLabel(action: KolTicker['action'], isZh: boolean) {
const labels: Record<KolTicker['action'], string> = {
buy: 'BUY',
sell: 'SELL',
bullish: 'Bullish',
bearish: 'Bearish',
mention: 'Mention',
}
return labels[action]
}
function windowLabel(days: number, isZh: boolean) {
if (days === 1) return isZh ? '今日' : 'Today'
return isZh ? `${days}` : `Last ${days}d`
}
function changeLabel(changeType: KolHoldingChange['change_type'], isZh: boolean) {
const labels: Record<KolHoldingChange['change_type'], string> = {
new_position: 'New',
increased: 'Added',
decreased: 'Reduced',
closed: 'Closed',
}
return labels[changeType]
}
function divergenceMeta(signalType: KolDivergence['signal_type'], isZh: boolean) {
return signalType === 'divergence'
? {
label: '⚠️ Divergence',
color: '#f59e0b',
bg: '#f59e0b22',
tip: 'Public stance and wallet action disagree. Follow the wallet, not the words.',
}
: {
label: '✅ Alignment',
color: '#22c55e',
bg: '#22c55e22',
tip: 'Public stance and wallet action align, which increases confidence.',
}
}
function directionLabel(direction: KolDivergence['direction'], isZh: boolean) {
if (direction === 'long') return 'Long ▲'
return 'Short ▼'
}
function postActionLabel(action: string, isZh: boolean) {
const labels: Record<string, string> = {
buy: 'BUY',
sell: 'SELL',
bullish: 'Bullish post',
bearish: 'Bearish post',
}
return labels[action] ?? action
}
function chainActionLabel(action: string, isZh: boolean) {
const labels: Record<string, string> = {
new_position: 'Opened on-chain',
increased: 'Added on-chain',
decreased: 'Reduced on-chain',
closed: 'Closed on-chain',
}
return labels[action] ?? action
}
function sourceLabel(source: string, isZh: boolean) {
if (source === 'substack') return 'Substack'
if (source === 'podcast') return 'Podcast'
if (source === 'twitter') return 'X'
return source
}
function formatShortUsd(value: number) {
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`
return `$${(value / 1e3).toFixed(0)}K`
}
function DigestWidget({
onTickerClick,
isZh,
initialDigest = null,
}: {
onTickerClick: (ticker: string) => void
isZh: boolean
initialDigest?: KolDigest | null
}) {
const [days, setDays] = useState<number>(7)
const [data, setData] = useState<KolDigest | null>(initialDigest)
const [loading, setLoading] = useState(initialDigest === null)
const [err, setErr] = useState('')
useEffect(() => {
if (initialDigest === null || days !== (initialDigest?.window_days ?? 7)) {
setLoading(true)
}
swrFetch(
`kol-digest-${days}`,
15 * 60_000,
() => getKolDigest(days),
fresh => setData(fresh),
)
.then(d => { setData(d); setErr('') })
.catch(e => setErr(e instanceof Error ? e.message : (isZh ? '加载失败' : 'Failed to load digest')))
.finally(() => setLoading(false))
}, [days, initialDigest, isZh])
return (
<div style={{
marginBottom: 16, padding: 'clamp(12px, 3vw, 18px)',
borderRadius: 12, background: 'var(--bg-card)',
border: '1px solid var(--border)',
}}>
<div style={{
display: 'flex', justifyContent: 'space-between',
alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 12,
}}>
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)',
letterSpacing: 1, textTransform: 'uppercase',
marginBottom: 2 }}>
KOL signal digest
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
{data
? `${data.post_count} posts · ${data.ticker_count} assets`
: 'Loading…'}
</div>
</div>
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{WINDOW_OPTIONS.map(daysOption => (
<button
key={daysOption}
onClick={() => setDays(daysOption)}
className={`nav-tab ${days === daysOption ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}
>
{windowLabel(daysOption, isZh)}
</button>
))}
</div>
</div>
{loading && (
<div style={{ padding: 16, textAlign: 'center', color: 'var(--ink-3)',
fontSize: 12 }}>Loading</div>
)}
{err && (
<div style={{ padding: 12, color: '#dc2626', fontSize: 12 }}>{err}</div>
)}
{!loading && !err && data && data.tickers.length === 0 && (
<div style={{ padding: 16, textAlign: 'center', color: 'var(--ink-3)',
fontSize: 13 }}>
No actionable calls in this window. Try a longer range.
</div>
)}
{!loading && !err && data && data.tickers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{data.tickers.map(t => <DigestTickerChip
key={t.ticker} t={t} isZh={isZh} onClick={() => onTickerClick(t.ticker)} />)}
</div>
)}
</div>
)
}
function DigestTickerChip({
t, onClick, isZh,
}: { t: KolDigestTicker; onClick: () => void; isZh: boolean }) {
const sideColor = t.side === 'long' ? '#16a34a'
: t.side === 'short' ? '#dc2626' : '#9ca3af'
const sideLabel = t.side === 'long'
? 'Long'
: t.side === 'short'
? 'Short'
: 'Mention'
const actionLabel = t.dominant_action === 'buy' ? 'BUY'
: t.dominant_action === 'sell' ? 'SELL'
: sideLabel
return (
<button
onClick={onClick}
title={`${t.kols.map(k => '@' + k).join(', ')} · ${t.post_count} signals`}
style={{
display: 'inline-flex', flexDirection: 'column',
alignItems: 'flex-start', gap: 4,
padding: '8px 12px', borderRadius: 10,
background: 'var(--bg-sunk)',
border: `1px solid ${sideColor}55`,
cursor: 'pointer', textAlign: 'left',
minWidth: 110,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<strong style={{ fontSize: 15, color: 'var(--ink-1)' }}>
{t.ticker}
</strong>
<span style={{
fontSize: 10, fontWeight: 700, color: sideColor,
padding: '1px 5px', borderRadius: 4,
background: `${sideColor}22`,
}}>
{actionLabel}
</span>
</div>
<div style={{ fontSize: 10, color: 'var(--ink-3)' }}>
{t.kol_count} KOL · {(t.max_conviction * 100).toFixed(0)}%
</div>
<div style={{
fontSize: 10, color: 'var(--ink-3)',
maxWidth: 160, whiteSpace: 'nowrap',
overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{t.kols.map(k => '@' + k).join(', ')}
</div>
</button>
)
}
// ── On-chain changes widget ───────────────────────────────────────────────
const CHANGE_COLOR: Record<KolHoldingChange['change_type'], string> = {
new_position: '#16a34a',
increased: '#22c55e',
decreased: '#f59e0b',
closed: '#dc2626',
}
function OnchainWidget({
isZh,
dateLocale,
initialChanges = null,
}: {
isZh: boolean
dateLocale: string
initialChanges?: KolHoldingChange[] | null
}) {
const [data, setData] = useState<KolHoldingChange[]>(initialChanges ?? [])
const [loading, setLoading] = useState(initialChanges === null)
const [days, setDays] = useState(7)
useEffect(() => {
if (initialChanges === null || days !== 7) {
setLoading(true)
}
swrFetch(
`kol-changes-${days}`,
30 * 60_000,
() => getKolChanges({ days }),
fresh => setData(fresh.changes),
)
.then(r => setData(r.changes))
.catch(() => setData([]))
.finally(() => setLoading(false))
}, [days, initialChanges])
return (
<div style={{
marginBottom: 16, padding: 'clamp(12px, 3vw, 18px)',
borderRadius: 12, background: 'var(--bg-card)',
border: '1px solid var(--border)',
}}>
<div style={{
display: 'flex', justifyContent: 'space-between',
alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 12,
}}>
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: 1,
textTransform: 'uppercase', marginBottom: 2 }}>
On-chain positioning
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
{loading
? 'Loading…'
: data.length === 0
? 'No tracked wallets yet. Add KOL addresses and this feed will populate automatically.'
: `${data.length} changes`}
</div>
</div>
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{([1, 7, 30] as const).map(d => (
<button key={d} onClick={() => setDays(d)}
className={`nav-tab ${days === d ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}>
{windowLabel(d, isZh)}
</button>
))}
</div>
</div>
{!loading && data.length === 0 && (
<div style={{
padding: '12px 16px', borderRadius: 8,
background: 'var(--bg-sunk)',
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6,
}}>
<>
Add tracked wallets through <code style={{ fontSize: 11 }}>POST /api/kol/wallets</code>,
or use{' '}
<a href="https://platform.arkhamintelligence.com" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--accent)' }}>Arkham Intelligence</a>{' '}
to discover labeled addresses. Ethereum, Solana, and Hyperliquid are supported.
</>
</div>
)}
{!loading && data.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{data.slice(0, 20).map(c => {
const color = CHANGE_COLOR[c.change_type]
const label = changeLabel(c.change_type, isZh)
return (
<div key={c.id} style={{
display: 'flex', alignItems: 'center', gap: 8,
flexWrap: 'wrap', // ← prevent horizontal overflow on mobile
padding: '8px 12px', borderRadius: 8,
background: 'var(--bg-sunk)',
borderLeft: `3px solid ${color}`,
}}>
<span style={{
fontSize: 10, fontWeight: 700, color,
padding: '2px 6px', borderRadius: 4,
background: `${color}22`, flexShrink: 0,
}}>
{label}
</span>
<strong style={{ fontSize: 14 }}>{c.ticker}</strong>
<span style={{
fontSize: 12, color: 'var(--ink-3)',
flex: '1 1 140px', minWidth: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
@{c.handle}
{c.usd_after != null && c.usd_after > 0 &&
` · ${formatShortUsd(c.usd_after)}`}
{c.pct_change != null &&
` (${c.pct_change > 0 ? '+' : ''}${c.pct_change.toFixed(0)}%)`}
</span>
<span style={{ fontSize: 11, color: 'var(--ink-3)', flexShrink: 0 }}>
{new Date(c.detected_at).toLocaleDateString(dateLocale)}
</span>
</div>
)
})}
</div>
)}
</div>
)
}
// ── Talks-vs-trades divergence widget ────────────────────────────────────────
const DIR_COLOR = { long: '#16a34a', short: '#dc2626' }
function DivergenceWidget({
isZh,
dateLocale,
initialItems = null,
}: {
isZh: boolean
dateLocale: string
initialItems?: KolDivergence[] | null
}) {
const [data, setData] = useState<KolDivergence[]>(initialItems ?? [])
const [loading, setLoading] = useState(initialItems === null)
const [filter, setFilter] = useState<'all' | 'divergence' | 'alignment'>('all')
const [days, setDays] = useState(30)
useEffect(() => {
if (initialItems === null || filter !== 'all' || days !== 30) {
setLoading(true)
}
swrFetch(
`kol-divergence-${filter}-${days}`,
30 * 60_000,
() => getKolDivergence({ signal_type: filter === 'all' ? undefined : filter, days }),
fresh => setData(fresh.items),
)
.then(r => setData(r.items))
.catch(() => setData([]))
.finally(() => setLoading(false))
}, [days, filter, initialItems])
const divCount = data.filter(d => d.signal_type === 'divergence').length
const aliCount = data.filter(d => d.signal_type === 'alignment').length
return (
<div style={{
marginBottom: 16, padding: 'clamp(12px, 3vw, 18px)',
borderRadius: 12, background: 'var(--bg-card)',
border: '1px solid var(--border)',
}}>
{/* Header */}
<div style={{
display: 'flex', justifyContent: 'space-between',
alignItems: 'flex-start', flexWrap: 'wrap', gap: 8, marginBottom: 12,
}}>
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: 1,
textTransform: 'uppercase', marginBottom: 2 }}>
Talks vs trades
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
{loading
? 'Loading…'
: data.length === 0
? 'No cross-signals yet. This feed needs both a post and a wallet move.'
: `⚠️ ${divCount} divergences · ✅ ${aliCount} alignments`}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap',
maxWidth: '100%', overflowX: 'auto' }}>
{/* filter tabs */}
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{(['all', 'divergence', 'alignment'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
className={`nav-tab ${filter === f ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 11,
whiteSpace: 'nowrap' }}>
{f === 'all'
? (isZh ? '全部' : 'All')
: f === 'divergence'
? (isZh ? '⚠️ 不符' : '⚠️ Divergence')
: (isZh ? '✅ 一致' : '✅ Alignment')}
</button>
))}
</div>
{/* day tabs */}
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{([7, 30, 90] as const).map(d => (
<button key={d} onClick={() => setDays(d)}
className={`nav-tab ${days === d ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 11,
whiteSpace: 'nowrap' }}>
{windowLabel(d, isZh)}
</button>
))}
</div>
</div>
</div>
{/* Rows */}
{!loading && data.length === 0 && (
<div style={{
padding: '12px 16px', borderRadius: 8, background: 'var(--bg-sunk)',
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.7,
}}>
<>
This module needs two pieces of evidence: a directional KOL post extracted by AI,
and a matching wallet position change in the same token within ±7 days.<br />
The wallet snapshot layer is still early, so signal count will grow with daily data.
</>
</div>
)}
{!loading && data.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.slice(0, 20).map(d => {
const meta = divergenceMeta(d.signal_type, isZh)
const dirColor = DIR_COLOR[d.direction]
return (
<div key={d.id} style={{
padding: '10px 14px', borderRadius: 10,
background: 'var(--bg-sunk)',
borderLeft: `3px solid ${meta.color}`,
display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center',
}}
title={meta.tip}
>
{/* Signal badge */}
<span style={{
fontSize: 10, fontWeight: 700, color: meta.color,
padding: '2px 7px', borderRadius: 4, background: meta.bg,
flexShrink: 0, whiteSpace: 'nowrap',
}}>
{meta.label}
</span>
{/* Ticker + direction */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<strong style={{ fontSize: 15 }}>{d.ticker}</strong>
<span style={{
fontSize: 11, fontWeight: 700, color: dirColor,
padding: '1px 6px', borderRadius: 4,
background: `${dirColor}22`,
}}>
{directionLabel(d.direction, isZh)}
</span>
</div>
{/* KOL + actions — flex: 1 1 0 with minWidth:0 so it shrinks on narrow screens */}
<div style={{ flex: '1 1 140px', fontSize: 12, color: 'var(--ink-2)',
minWidth: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span style={{ color: 'var(--ink-3)' }}>@{d.handle}</span>
{' '}
<span style={{ color: 'var(--ink-3)' }}>
{postActionLabel(d.post_action, isZh)}
</span>
<span style={{ color: 'var(--ink-3)', margin: '0 6px' }}>{isZh ? 'vs' : 'vs'}</span>
<span style={{ color: 'var(--ink-3)' }}>
{chainActionLabel(d.onchain_action, isZh)}
</span>
{d.usd_after != null && d.usd_after > 0 &&
<span style={{ color: 'var(--ink-2)', marginLeft: 6 }}>
{formatShortUsd(d.usd_after)}
</span>}
</div>
{/* Time info */}
<div style={{ fontSize: 11, color: 'var(--ink-3)',
flexShrink: 0, textAlign: 'right' }}>
<div>
{`${d.days_apart?.toFixed(1) ?? '?'} days apart`}
</div>
<div>{new Date(d.onchain_at).toLocaleDateString(dateLocale)}</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
function TickerChips({ tickers, isZh }: { tickers: KolTicker[]; isZh: boolean }) {
if (!tickers.length) {
return <span style={{ color: 'var(--ink-3)', fontSize: 12 }}></span>
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tickers.map((t, i) => (
<span
key={`${t.ticker}-${i}`}
title={t.quote}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 999,
fontSize: 11, fontWeight: 600,
background: 'var(--bg-sunk)',
border: `1px solid ${ACTION_COLOR[t.action]}33`,
color: ACTION_COLOR[t.action],
}}
>
{t.ticker}
<span style={{ opacity: 0.7, fontWeight: 500 }}>
{actionLabel(t.action, isZh)}
</span>
<span style={{ opacity: 0.55, fontWeight: 500 }}>
{(t.conviction * 100).toFixed(0)}
</span>
</span>
))}
</div>
)
}
function PostDetail({
post, onClose, isZh, dateLocale,
}: { post: KolPostDetail; onClose: () => void; isZh: boolean; dateLocale: string }) {
// Lock body scroll while open
useEffect(() => {
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [])
const postSourceLabel = sourceLabel(post.source, isZh)
const node = (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.82)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
zIndex: 9999, display: 'flex', alignItems: 'flex-start',
justifyContent: 'center',
padding: 'clamp(8px, 4vw, 40px) clamp(8px, 3vw, 20px)',
overflowY: 'auto',
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
background: 'var(--bg)', borderRadius: 14,
maxWidth: 820, width: '100%',
padding: 'clamp(16px, 4vw, 28px)',
border: '1px solid var(--line)',
boxShadow: '0 24px 80px rgba(0,0,0,0.6)',
}}
>
{/* ── Header: 标题 + 关闭 + 查看原帖 ── */}
<div style={{
display: 'flex', justifyContent: 'space-between',
alignItems: 'flex-start', gap: 12, marginBottom: 12,
}}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginBottom: 6 }}>
@{post.kol_handle} · {postSourceLabel} · {new Date(post.published_at).toLocaleString(dateLocale)}
</div>
<h2 style={{
margin: 0, fontSize: 'clamp(16px, 4.5vw, 22px)',
lineHeight: 1.3, wordBreak: 'break-word',
}}>
{post.title || (isZh ? '(无标题)' : '(Untitled)')}
</h2>
</div>
{/* 右上角按钮组 */}
<div style={{ display: 'flex', gap: 8, flexShrink: 0, alignItems: 'center' }}>
{post.url && (
<a
href={post.url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '5px 12px', borderRadius: 6,
background: 'var(--amber)', color: '#000',
fontSize: 12, fontWeight: 700, textDecoration: 'none',
whiteSpace: 'nowrap',
}}
>
{isZh ? `查看原帖 ${postSourceLabel}` : `Open original ${postSourceLabel}`}
</a>
)}
<button
onClick={onClose}
aria-label="Close"
style={{
background: 'var(--bg-sunk)', border: '1px solid var(--line)',
borderRadius: 6, padding: '5px 10px', cursor: 'pointer',
color: 'var(--ink-2)', fontSize: 14, lineHeight: 1,
}}
></button>
</div>
</div>
{/* ── AI 摘要 ── */}
{post.summary && (
<div style={{
margin: '12px 0', padding: 12, borderRadius: 8,
background: 'var(--bg-sunk)', borderLeft: '3px solid var(--amber)',
fontSize: 14, lineHeight: 1.6,
}}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4,
letterSpacing: 1, textTransform: 'uppercase' }}>
{isZh ? 'AI 摘要' : 'AI summary'}
</div>
{post.summary}
</div>
)}
{/* ── 喊单标的 ── */}
{post.tickers.length > 0 && (
<div style={{ margin: '12px 0' }}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 6,
letterSpacing: 1, textTransform: 'uppercase' }}>
{isZh ? '提取标的' : 'Extracted assets'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{post.tickers.map((t, i) => (
<div key={i} style={{
padding: 10, borderRadius: 8,
background: 'var(--bg-sunk)',
borderLeft: `3px solid ${ACTION_COLOR[t.action]}`,
}}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
<strong style={{ fontSize: 14 }}>{t.ticker}</strong>
<span style={{ color: ACTION_COLOR[t.action], fontSize: 12, fontWeight: 600 }}>
{actionLabel(t.action, isZh)}
</span>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{isZh ? '信心分' : 'Conviction'} {(t.conviction * 100).toFixed(0)}%
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--ink-2)',
fontStyle: 'italic', lineHeight: 1.5 }}>
&ldquo;{t.quote}&rdquo;
</div>
</div>
))}
</div>
</div>
)}
{/* ── 原文 ── */}
<div style={{
marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--line)',
}}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 8,
letterSpacing: 1, textTransform: 'uppercase' }}>
{isZh ? '原文摘录' : 'Source excerpt'}
</div>
<div style={{
whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7,
color: 'var(--ink-2)', maxHeight: '40vh', overflowY: 'auto',
padding: 12, borderRadius: 8, background: 'var(--bg-sunk)',
wordBreak: 'break-word',
}}>
{post.raw_text}
</div>
</div>
</div>
</div>
)
if (typeof document === 'undefined') return null
return createPortal(node, document.body)
}
interface KolPageProps {
initialPosts?: KolPostSummary[] | null
initialDigest?: KolDigest | null
initialChanges?: KolHoldingChange[] | null
initialDivergence?: KolDivergence[] | null
}
export default function KolPage({
initialPosts = null,
initialDigest = null,
initialChanges = null,
initialDivergence = null,
}: KolPageProps) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const dateLocale = isZh ? 'zh-CN' : 'en-US'
const [posts, setPosts] = useState<KolPostSummary[]>(initialPosts ?? [])
const [loading, setLoading] = useState(initialPosts === null)
const [err, setErr] = useState('')
const [openPost, setOpenPost] = useState<KolPostDetail | null>(null)
const [handleFilter, setHandleFilter] = useState<string>('all')
const [tickerFilter, setTickerFilter] = useState<string | null>(null)
useEffect(() => {
swrFetch(
'kol-posts-100',
15 * 60_000, // 15 min TTL — KOL feed is ingested daily
() => getKolPosts({ limit: 100 }),
fresh => setPosts(fresh.items),
)
.then(r => { setPosts(r.items); setErr('') })
.catch(e => setErr(e instanceof Error ? e.message : (isZh ? '加载失败' : 'Failed to load posts')))
.finally(() => setLoading(false))
}, [isZh])
const handles = useMemo(() => {
const set = new Set(posts.map(p => p.kol_handle))
return ['all', ...Array.from(set)]
}, [posts])
const filtered = useMemo(() => {
let out = handleFilter === 'all' ? posts : posts.filter(p => p.kol_handle === handleFilter)
if (tickerFilter) {
out = out.filter(p => p.tickers.some(t => t.ticker === tickerFilter))
}
return out
}, [posts, handleFilter, tickerFilter])
async function openDetail(id: number) {
try {
const detail = await getKolPost(id)
setOpenPost(detail)
} catch (e) {
setErr(e instanceof Error ? e.message : (isZh ? '详情加载失败' : 'Failed to load detail'))
}
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? 'KOL 信号' : 'KOL Signals'}</h1>
<p className="page-sub">
{`Track long-form KOL theses and extracted asset calls · ${posts.length} posts`}
</p>
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5, marginBottom: 16 }}>
This module ingests long-form KOL writing and show notes, extracts explicit asset views with AI, then cross-checks those views against wallet behavior. Open any row to inspect the original text and supporting quote.
</div>
<DigestWidget
isZh={isZh}
initialDigest={initialDigest}
onTickerClick={(sym) =>
setTickerFilter(prev => prev === sym ? null : sym)}
/>
<OnchainWidget
isZh={isZh}
dateLocale={dateLocale}
initialChanges={initialChanges}
/>
<DivergenceWidget
isZh={isZh}
dateLocale={dateLocale}
initialItems={initialDivergence}
/>
{tickerFilter && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
marginBottom: 12, fontSize: 12,
}}>
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '当前筛选标的:' : 'Filtered asset:'}</span>
<strong>{tickerFilter}</strong>
<button
onClick={() => setTickerFilter(null)}
style={{
background: 'var(--bg-sunk)', border: '1px solid var(--border)',
borderRadius: 4, padding: '2px 8px',
cursor: 'pointer', fontSize: 11, color: 'var(--ink-2)',
}}
>{isZh ? '清除 ✕' : 'Clear ✕'}</button>
</div>
)}
{handles.length > 2 && (
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)', marginBottom: 12 }}>
{handles.map(h => (
<button
key={h}
onClick={() => setHandleFilter(h)}
className={`nav-tab ${handleFilter === h ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer' }}
>
{h === 'all' ? (isZh ? `全部 (${posts.length})` : `All (${posts.length})`) : `@${h}`}
</button>
))}
</div>
)}
{loading && <KolPostsSkeleton />}
{err && <div style={{ padding: 20, color: '#dc2626' }}>{isZh ? `错误:${err}` : `Error: ${err}`}</div>}
{!loading && !err && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.length === 0 && (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--ink-3)' }}>
{isZh
? '当前没有数据,等待下一轮抓取任务(每天 01:15 UTC)。'
: 'No data yet. Wait for the next feed ingestion run (daily 01:15 UTC).'}
</div>
)}
{filtered.map(p => (
<div
key={p.id}
onClick={() => openDetail(p.id)}
style={{
padding: 14, borderRadius: 10,
background: 'var(--bg-card)',
border: '1px solid var(--border)',
cursor: 'pointer',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border)')}
>
{/* 顶部:作者 + 右侧操作区 */}
<div style={{ display: 'flex', justifyContent: 'space-between',
alignItems: 'center', gap: 8, marginBottom: 6,
flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}>
@{p.kol_handle} · {sourceLabel(p.source, isZh)} · {new Date(p.published_at).toLocaleDateString(dateLocale)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{p.analyzed_at
? (isZh ? '✓ 已分析' : '✓ Analyzed')
: (isZh ? '⏳ 待分析' : '⏳ Pending')}
</span>
{p.url && (
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={{
fontSize: 11, fontWeight: 600,
color: 'var(--amber)', textDecoration: 'none',
padding: '2px 8px', borderRadius: 4,
border: '1px solid color-mix(in srgb, var(--amber) 35%, transparent)',
whiteSpace: 'nowrap',
}}
>
{isZh ? '原帖 ↗' : 'Source ↗'}
</a>
)}
</div>
</div>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 6,
wordBreak: 'break-word' }}>
{p.title || (isZh ? '(无标题)' : '(Untitled)')}
</div>
{p.summary && (
<div style={{ fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.5,
marginBottom: 8 }}>
{p.summary}
</div>
)}
<TickerChips tickers={p.tickers} isZh={isZh} />
</div>
))}
</div>
)}
{openPost && <PostDetail post={openPost} onClose={() => setOpenPost(null)} isZh={isZh} dateLocale={dateLocale} />}
</div>
)
}
function KolPostsSkeleton() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 8 }}>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="skeleton-card">
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div className="skeleton sk-line-sm" style={{ width: 160 }} />
<div className="skeleton sk-line-sm" style={{ width: 56 }} />
</div>
<div className="skeleton sk-title sk-w-3q" />
<div className="skeleton sk-line sk-w-full" />
<div className="skeleton sk-line" style={{ width: '85%' }} />
<div style={{ display: 'flex', gap: 6, marginTop: 2 }}>
<div className="skeleton sk-line-sm" style={{ width: 48 }} />
<div className="skeleton sk-line-sm" style={{ width: 48 }} />
</div>
</div>
))}
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { getKolChanges, getKolDigest, getKolDivergence, getKolPosts } from '@/lib/api'
import type { Metadata } from 'next'
import KolPageClient from './KolPageClient'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
export const revalidate = 30
interface KolPageProps {
params: Promise<{ locale: string }>
}
export async function generateMetadata({
params,
}: KolPageProps): Promise<Metadata> {
const { locale } = await params
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return {
title: isZh ? 'KOL 信号与言行偏离追踪' : 'KOL Signals & Talks-vs-Trades Divergence',
description: isZh
? '从 15 个加密 KOL 信息源中提取可执行观点,覆盖 Arthur Hayes、Delphi Digital、Bankless、Empire、Unchained 等,并追踪“说法”和链上仓位是否一致。'
: 'AI-extracted crypto signals from 19 KOL feeds: Arthur Hayes, Delphi Digital, Bankless, Empire, Unchained and more. Includes talks-vs-trades divergence — when KOL wallets contradict their public posts.',
keywords: isZh
? [
'KOL 加密信号',
'Arthur Hayes 信号',
'Delphi Digital 分析',
'KOL 钱包追踪',
'言行不一致',
'链上 KOL 跟踪',
'Substack 加密信号',
'播客加密信号',
]
: [
'KOL crypto signals',
'Arthur Hayes signals',
'Delphi Digital analysis',
'crypto KOL tracker',
'talks vs trades',
'smart money divergence',
'on-chain KOL tracking',
'Substack crypto signals',
'crypto podcast signals',
'KOL wallet tracking',
],
openGraph: {
title: isZh ? 'KOL 信号与言行偏离 | Trump Alpha' : 'KOL Signals & Talks-vs-Trades | Trump Alpha',
description: isZh
? '每天分析 KOL 长文、播客和公开观点,再与链上仓位交叉验证,找出真正有信息增量的观点与偏离。'
: '19 KOL feeds (Hayes, Bankless, Empire…) AI-scored daily. Plus talks-vs-trades: when their wallets contradict their words.',
},
alternates: {
canonical: `${siteUrl}/${locale}/kol`,
languages: {
en: `${siteUrl}/en/kol`,
'x-default': `${siteUrl}/en/kol`,
},
},
}
}
export default async function KolPage() {
const [posts, digest, changes, divergence] = await Promise.all([
getKolPosts({ limit: 100 }).catch(() => null),
getKolDigest(7).catch(() => null),
getKolChanges({ days: 7 }).catch(() => null),
getKolDivergence({ days: 30 }).catch(() => null),
])
return (
<KolPageClient
initialPosts={posts?.items ?? null}
initialDigest={digest}
initialChanges={changes?.changes ?? null}
initialDivergence={divergence?.items ?? null}
/>
)
}
+33 -7
View File
@@ -1,22 +1,45 @@
import type { Metadata } from 'next'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
import { getMessages, getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { locales } from '@/i18n'
import Providers from './Providers'
import Navbar from '@/components/nav/Navbar'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
interface LayoutProps {
children: React.ReactNode
params: { locale: string }
params: Promise<{ locale: string }>
}
export default async function LocaleLayout({ children, params: { locale } }: LayoutProps) {
export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> {
const { locale } = await params
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return {
alternates: {
canonical: `${siteUrl}/${locale}`,
languages: {
'en': `${siteUrl}/en`,
'x-default': `${siteUrl}/en`,
},
},
openGraph: {
locale: isZh ? 'zh_CN' : 'en_US',
alternateLocale: isZh ? ['en_US'] : ['zh_CN'],
},
}
}
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { locale } = await params
if (!locales.includes(locale as (typeof locales)[number])) {
notFound()
}
const messages = await getMessages()
const t = await getTranslations({ locale, namespace: 'footer' })
const href = (path: string) => `/${locale}${path}`
return (
@@ -35,10 +58,13 @@ export default async function LocaleLayout({ children, params: { locale } }: Lay
color: 'var(--ink-3)',
marginTop: 48,
}}>
<span>© {new Date().getFullYear()} TrumpSignal</span>
<Link href={href('/privacy')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>Privacy</Link>
<Link href={href('/terms')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>Terms</Link>
<Link href={href('/contact')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>Contact</Link>
<span>© {new Date().getFullYear()} Trump Alpha</span>
<Link href={href('/methodology')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>{t('methodology')}</Link>
<Link href={href('/glossary')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>{t('glossary')}</Link>
<Link href={href('/case-studies')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>{t('caseStudies')}</Link>
<Link href={href('/privacy')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>{t('privacy')}</Link>
<Link href={href('/terms')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>{t('terms')}</Link>
<Link href={href('/contact')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>{t('contact')}</Link>
</footer>
</Providers>
</NextIntlClientProvider>
+492
View File
@@ -0,0 +1,492 @@
import type { Metadata } from 'next'
import Link from 'next/link'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
type Locale = 'en' | 'zh'
type MethodSection = {
tag: string
title: string
answer: string
paragraphs: string[]
formula?: string
bullets?: string[]
limitation: string
}
type MethodologyCopy = {
title: string
description: string
keywords: string[]
ogTitle: string
ogDescription: string
heroTitle: string
heroSubtitle: string
intro: string
sections: MethodSection[]
footer: string
footerLinks: {
contact: string
glossary: string
caseStudies: string
}
}
function getCopy(locale: string): MethodologyCopy {
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
if (isZh) {
return {
title: '信号方法论 - Trump Alpha',
description:
'完整解释 Trump Alpha 六个信号引擎的工作方式,包括 Trump Truth Social 语义打分、BTC 周期底部 2-of-3 共振、KOL 观点抽取、言行偏离、资金费率反转和 Hyperliquid 自动执行风控模型。',
keywords: [
'加密信号方法论',
'AHR999',
'Pi Cycle Bottom',
'200周均线',
'Trump Truth Social 信号',
'KOL 观点抽取',
'言行偏离',
'资金费率反转',
'Hyperliquid 自动交易',
],
ogTitle: 'Signal Methodology | Trump Alpha',
ogDescription:
'Trump Alpha 六个引擎的完整方法论:数据源、触发规则、执行方式和已知限制。',
heroTitle: '信号方法论',
heroSubtitle: '从原始数据到交易动作,逐个解释每个引擎到底怎么工作',
intro:
'Trump Alpha 不是一个单一策略,而是六个相互独立的引擎。每个引擎都有自己的数据源、触发条件和失败模式。这里给出的是当前线上版本的真实逻辑,而不是营销版摘要。',
sections: [
{
tag: 'Signal 1 · 实时事件',
title: 'Trump Truth Social 情绪引擎',
answer:
'这个引擎实时轮询 Donald Trump 的 Truth Social 帖子,并在几秒内判断帖子是否会对加密市场形成短线方向性冲击。',
paragraphs: [
'系统只处理带有文字内容的原帖或转帖。纯图片、纯链接和没有可解释文本的信息会被过滤掉,因为这些内容更容易产生误触发。',
'每条帖子会被送进结构化语义分类流程,输出方向、主要资产和置信度。只有当置信度高于用户门槛,且交易时间窗、日预算、仓位上限都允许时,自动执行层才会放行。',
],
formula: `if classification != NOISE
AND confidence >= user_threshold
AND current_time in active_window
AND daily_spend < daily_cap
=> open isolated-margin trade`,
bullets: [
'方向标签:LONG / SHORT / NOISE',
'默认场景:突发政策表态、监管口径、关税升级、美元或财政扩张相关表述',
'风控层:逐仓、止盈止损、日预算上限、活跃时段、单笔仓位上限',
],
limitation:
'这个模型理解的是“帖子语义”,不是完整宏观环境。它很适合事件驱动,但不保证在风险偏好急转直下的市场里仍有同样的盈亏比。',
},
{
tag: 'Signal 2 · 日频 · BTC',
title: 'BTC 周期底部 2-of-3 共振',
answer:
'当前线上 BTC 系统使用三项经典底部指标做 2-of-3 共振,只在宏观底部区域附近给出低频的 long-only 信号。',
paragraphs: [
'这套逻辑不再依赖付费链上数据,而是改成更稳健、更容易验证的公开价格框架。核心思想没变:抓大级别熊市尾声,而不是做日内择时。',
'扫描器每天只跑一次,因为这不是高频策略。它要解决的不是“今天能不能涨 2%”,而是“是否正在进入一个值得持有数月的反转区间”。',
],
formula: `signal fires if count(
AHR999 < 0.45,
price <= 200-week MA * 1.05,
Pi Cycle Bottom true
) >= 2`,
bullets: [
'2-of-3 触发时,信心分高于单指标方案',
'只做 long-only,不做趋势中的逆势 short',
'退出更偏向保护利润和周期持有,而不是固定止盈',
],
limitation:
'这套方法天生滞后于最低点。它接受“抄不到最底”,换取更少的中继下跌误判。最大的风险是把中期调整误判成结构性底部。',
},
{
tag: 'Signal 3 · 日频 · KOL',
title: 'KOL 长文与播客观点抽取',
answer:
'KOL 引擎每天抓取长文、博客和播客描述,提取出明确的资产观点,并区分是明确买卖、方向性看法,还是只是提及。',
paragraphs: [
'比起碎片化推文,长文和播客摘要更容易包含完整论证、仓位语气和时间窗口,因此对 AI 提取更友好,也更适合作为可回溯的数据资产。',
'输出结构不只包含 ticker,还包含动作类型、置信度和最短支撑引用句。这样前端和后续检索系统都能拿到“观点 + 证据”的组合,而不只是一个标签。',
],
formula: `{
"summary": "<thesis>",
"tickers": [
{
"ticker": "BTC",
"action": "buy | sell | bullish | bearish | mention",
"conviction": 0.0-1.0,
"quote": "<supporting sentence>"
}
]
}`,
bullets: [
'buy / sell 表示作者明确表达了交易动作',
'bullish / bearish 表示方向明确但没有出现实际下单表述',
'mention 仅表示提到标的,不足以视作交易观点',
],
limitation:
'KOL 观点提取的难点在于上下文。历史复盘、赞助口播、反讽和模糊措辞都会降低可信度,因此系统对噪音过滤比较保守。',
},
{
tag: 'Signal 4 · 日频 · 交叉验证',
title: 'Talks-vs-Trades 言行偏离',
answer:
'当 KOL 公开表达的方向,与其链上钱包在同一资产上的实际操作相反时,系统会把链上行为视作更高优先级的真实信号。',
paragraphs: [
'这个模块把“说了什么”和“做了什么”分开建模。公开叙事影响舆论,但仓位变化更接近真实风险暴露,因此两者冲突时,链上动作通常更有信息增量。',
'目前系统会在 KOL 发文后的 ±7 天窗口内查找同资产的仓位变化,并将新增/加仓归为 long action,把减仓/清仓归为 short action。',
],
formula: `LONG_INTENT + SHORT_ACTION => DIVERGENCE (short)
SHORT_INTENT + LONG_ACTION => DIVERGENCE (long)
LONG_INTENT + LONG_ACTION => ALIGNMENT
SHORT_INTENT + SHORT_ACTION => ALIGNMENT`,
bullets: [
'公开看多但链上减仓:偏离,方向按 short 解释',
'公开看空但链上加仓:偏离,方向按 long 解释',
'公开观点和链上仓位一致:一致性增强,不一定是反向机会',
],
limitation:
'当前可见性仍受限于已知钱包集合。一个 KOL 可能分散使用多个地址、CEX 或未标记账户,因此偏离信号属于高质量但不保证覆盖完整仓位。',
},
{
tag: 'Signal 5 · 小时级 · 衍生品',
title: '资金费率极值反转',
answer:
'资金费率引擎不是追涨杀跌,而是盯着拥挤度。当多头或空头过度拥挤,且极值开始均值回归时,系统会给出反向信号。',
paragraphs: [
'永续合约的 funding rate 能直观反映杠杆倾斜。如果市场长期是“多头在付钱”,说明做多拥挤;反过来则代表空头拥挤。极端拥挤往往在清算前夜。',
'当前前端展示的是 30 日累计 funding 和实时状态,便于直接判断是否进入危险区,而不是只看单个时间点的 funding 数字。',
],
formula: `30d cumulative funding >= +3% and mean-reverting => SHORT bias
30d cumulative funding <= -3% and mean-reverting => LONG bias`,
bullets: [
'用累计 funding 代替单点读数,更适合识别持续拥挤',
'方向是反向交易 crowd,而不是顺着 crowd',
'适合做拥挤度监控,也适合和其他系统做组合验证',
],
limitation:
'资金费率极值可以持续比预期更久。它更像条件成熟的提醒,而不是保证立刻反转的钟摆,因此执行层必须配合止损和仓位控制。',
},
{
tag: 'Signal 6 · 执行层',
title: 'Hyperliquid 自动交易与安全模型',
answer:
'自动交易层的职责不是“提高收益率”,而是把信号执行标准化,并在风控上强制落实逐仓、止损、预算和时间窗等约束。',
paragraphs: [
'Trump Alpha 通过 trade-only API key 与 Hyperliquid 连接,当前不会接触用户私钥或提现权限。这意味着系统能执行交易,但不能挪走资产。',
'自动执行目前只接入 Trump Truth Social 实时信号。BTC 周期底部、KOL 观点和言行偏离依旧定位为研究与辅助决策层,默认由用户手动处理。',
],
bullets: [
'最低置信度门槛',
'逐仓模式',
'开仓即挂止盈止损',
'日预算上限',
'活跃时间窗',
'单笔仓位上限',
],
limitation:
'执行层再稳,也无法把错误信号变成好交易。它能降低人为偏差,但不能替代策略本身的边际优势。',
},
],
footer: '方法论最后更新于 2026 年 5 月。页面会随着线上逻辑更新而同步修订。',
footerLinks: {
contact: '联系我们',
glossary: '术语表',
caseStudies: '案例研究',
},
}
}
return {
title: 'Signal Methodology — How Each Engine Works',
description:
"Full technical methodology behind Trump Alpha's live signal engines: Trump Truth Social AI scoring, BTC 2-of-3 macro-bottom confluence, KOL feed extraction, talks-vs-trades divergence, funding rate reversals, and the Hyperliquid auto-trader safety model.",
keywords: [
'crypto signal methodology',
'AHR999 explained',
'Pi Cycle Bottom',
'200-week moving average Bitcoin',
'Trump Truth Social AI scoring',
'KOL signal extraction',
'talks vs trades methodology',
'funding rate reversal signal',
'Hyperliquid isolated margin',
],
ogTitle: 'Signal Methodology | Trump Alpha',
ogDescription:
'How each Trump Alpha engine works: data sources, trigger logic, execution rules, and known limitations.',
heroTitle: 'Signal Methodology',
heroSubtitle: 'How each engine works, from raw input to trading decision',
intro:
'Trump Alpha is not one monolithic strategy. It is a stack of six independent engines, each with its own data source, trigger logic, and failure mode. This page documents the live production logic rather than a simplified marketing summary.',
sections: [
{
tag: 'Signal 1 · Real-time event',
title: 'Trump Truth Social Sentiment Engine',
answer:
'This engine watches Donald Trumps Truth Social feed in real time and scores whether a new post is likely to create a short-term directional move in crypto.',
paragraphs: [
'Only text-bearing original posts and reposts are scored. Pure media reposts, link dumps, and non-interpretable content are filtered out because they generate too many false positives.',
'Each post goes through a structured semantic classification step that returns direction, primary asset, and confidence. Auto-execution only proceeds when confidence clears the user threshold and all user-level trade constraints still allow a new position.',
],
formula: `if classification != NOISE
AND confidence >= user_threshold
AND current_time in active_window
AND daily_spend < daily_cap
=> open isolated-margin trade`,
bullets: [
'Direction labels: LONG / SHORT / NOISE',
'Typical catalysts: policy comments, tariff escalation, regulation, fiscal expansion, dollar weakness',
'Safety rails: isolated margin, TP/SL, daily budget cap, active hours, per-trade size cap',
],
limitation:
'The model interprets post semantics, not full macro context. It is strong for event-driven reaction, but it does not guarantee the same edge during broad risk-off regimes.',
},
{
tag: 'Signal 2 · Daily · BTC',
title: 'BTC Macro-Bottom 2-of-3 Confluence',
answer:
'The live BTC engine uses three classic bottom markers and only fires a low-frequency long-only signal when at least two of them agree.',
paragraphs: [
'The production system no longer depends on premium on-chain feeds. It now uses a more durable public-price framework that is easier to verify, easier to operate, and less brittle under vendor changes.',
'This scanner runs once a day because it is solving a cycle-level problem, not an intraday one. The question is not whether BTC can bounce today, but whether the market is entering a region that deserves multi-month exposure.',
],
formula: `signal fires if count(
AHR999 < 0.45,
price <= 200-week MA * 1.05,
Pi Cycle Bottom true
) >= 2`,
bullets: [
'2-of-3 agreement carries more weight than any single indicator',
'Long only by design; it does not try to fade uptrends with short signals',
'Exit logic is built around capital protection and cycle capture, not fixed profit targets',
],
limitation:
'This method is intentionally late relative to the exact wick low. It accepts that trade-off to reduce the chance of confusing a mid-cycle correction with a structural bottom.',
},
{
tag: 'Signal 3 · Daily · KOL',
title: 'KOL Long-Form Thesis Extraction',
answer:
'The KOL engine ingests long-form writing, blogs, and podcast descriptions, then extracts explicit asset views and separates true trade calls from soft mentions.',
paragraphs: [
'Long-form sources tend to include fuller reasoning, clearer sizing language, and more precise timing than short social posts. That makes them better raw material for a retrieval-friendly signal archive.',
'The output includes not only the ticker and action, but also a conviction score and the shortest supporting quote. That gives both the UI and downstream retrieval systems access to view plus evidence, not just a label.',
],
formula: `{
"summary": "<thesis>",
"tickers": [
{
"ticker": "BTC",
"action": "buy | sell | bullish | bearish | mention",
"conviction": 0.0-1.0,
"quote": "<supporting sentence>"
}
]
}`,
bullets: [
'buy / sell means the author explicitly states a trade action',
'bullish / bearish means directional intent without an explicit trade statement',
'mention means the asset appears but the text is not strong enough to treat as a call',
],
limitation:
'Context is the hard part. Historical references, sponsor segments, irony, and vague thesis language all reduce extraction quality, so the filters are intentionally conservative.',
},
{
tag: 'Signal 4 · Daily · Cross-check',
title: 'Talks-vs-Trades Divergence',
answer:
'When a KOLs public stance and wallet behavior point in opposite directions on the same asset, the engine treats the wallet move as the higher-priority truth signal.',
paragraphs: [
'This module models “what was said” separately from “what was done.” Public narrative can move sentiment, but wallet changes are closer to actual risk exposure. When the two conflict, the on-chain action usually contains more information.',
'The current implementation scans a ±7 day window around a KOL post and maps new positions or adds to long action, while reductions and exits map to short action.',
],
formula: `LONG_INTENT + SHORT_ACTION => DIVERGENCE (short)
SHORT_INTENT + LONG_ACTION => DIVERGENCE (long)
LONG_INTENT + LONG_ACTION => ALIGNMENT
SHORT_INTENT + SHORT_ACTION => ALIGNMENT`,
bullets: [
'Publicly bullish while reducing the wallet position becomes a short-style divergence',
'Publicly bearish while adding to the wallet position becomes a long-style divergence',
'Alignment boosts confidence, but it is not automatically a contrarian trade',
],
limitation:
'Coverage still depends on known wallets. A KOL can split risk across multiple addresses, exchanges, or unlabeled accounts, so divergence is high quality but not guaranteed full-balance visibility.',
},
{
tag: 'Signal 5 · Hourly · Derivatives',
title: 'Funding Rate Extreme Reversal',
answer:
'The funding engine is not chasing price. It watches crowding and looks for mean reversion after leverage has become one-sided enough to create liquidation risk.',
paragraphs: [
'Perpetual funding rates are a direct lens into leverage tilt. If longs keep paying, the long side is crowded. If shorts keep paying, the short side is crowded. Extreme crowding often precedes violent squeezes in the opposite direction.',
'The live UI emphasizes 30-day cumulative funding and current state, which makes crowding easier to interpret than isolated single-print readings.',
],
formula: `30d cumulative funding >= +3% and mean-reverting => SHORT bias
30d cumulative funding <= -3% and mean-reverting => LONG bias`,
bullets: [
'Cumulative funding is more useful than single-point funding for persistent crowd detection',
'The trade idea is to fade the crowd, not to join it',
'Useful as a standalone crowding monitor or as confirmation for other systems',
],
limitation:
'Funding extremes can persist longer than expected. The signal is a condition alert, not a guarantee of immediate reversal, so position sizing and stop logic still matter.',
},
{
tag: 'Signal 6 · Execution layer',
title: 'Hyperliquid Auto-Trader and Safety Model',
answer:
'The auto-trader exists to standardize execution and enforce risk constraints, not to manufacture edge out of a weak signal.',
paragraphs: [
'Trump Alpha connects to Hyperliquid through a trade-only API key class. The system can submit and manage trades, but it does not have withdrawal rights and does not touch wallet private keys.',
'Today the auto-trader is wired to the Trump Truth Social engine only. BTC macro-bottom, KOL thesis extraction, and divergence remain research and decision-support layers by default.',
],
bullets: [
'Minimum confidence threshold',
'Isolated margin',
'TP/SL attached at entry',
'Daily budget cap',
'Active trading window',
'Per-trade position cap',
],
limitation:
'Execution hygiene cannot turn a bad signal into a good trade. It reduces operational mistakes, but it cannot replace strategy edge.',
},
],
footer: 'Methodology last updated in May 2026. This page is revised whenever live logic changes.',
footerLinks: {
contact: 'Contact',
glossary: 'Glossary',
caseStudies: 'Case Studies',
},
}
}
export function generateMetadata({
params: { locale },
}: {
params: { locale: string }
}): Metadata {
const copy = getCopy(locale)
return {
title: copy.title,
description: copy.description,
keywords: copy.keywords,
openGraph: {
title: copy.ogTitle,
description: copy.ogDescription,
},
alternates: {
canonical: `${siteUrl}/${locale}/methodology`,
languages: {
en: `${siteUrl}/en/methodology`,
'x-default': `${siteUrl}/en/methodology`,
},
},
}
}
const SECTION_STYLE = {
borderTop: '1px solid var(--line)',
paddingTop: 32,
marginTop: 32,
}
const H2 = { fontSize: 20, fontWeight: 700, color: 'var(--ink)', marginBottom: 12, lineHeight: 1.3 }
const P = { fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.75, marginBottom: 12 }
const LI = { fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.75, marginBottom: 6 }
function Tag({ children, color = 'var(--amber)' }: { children: string; color?: string }) {
return (
<span style={{
display: 'inline-block',
fontSize: 10, fontWeight: 700, letterSpacing: '0.07em',
textTransform: 'uppercase', fontFamily: 'monospace',
padding: '3px 8px', borderRadius: 4,
background: `color-mix(in srgb, ${color} 12%, transparent)`,
color, marginBottom: 12,
}}>
{children}
</span>
)
}
function Formula({ children }: { children: React.ReactNode }) {
return (
<div style={{
fontFamily: 'monospace', fontSize: 13,
background: 'var(--bg-sunk)', border: '1px solid var(--line)',
borderRadius: 8, padding: '14px 18px',
color: 'var(--ink)', margin: '12px 0', overflowX: 'auto',
whiteSpace: 'pre-wrap',
}}>
{children}
</div>
)
}
export default function MethodologyPage({ params: { locale } }: { params: { locale: string } }) {
const copy = getCopy(locale)
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return (
<div className="page" style={{ maxWidth: 760 }}>
<div className="page-head">
<div>
<h1 className="page-title">{copy.heroTitle}</h1>
<p className="page-sub">{copy.heroSubtitle}</p>
</div>
</div>
<div className="card" style={{ padding: '28px 32px' }}>
<p style={{ ...P, color: 'var(--ink-3)', fontStyle: 'italic' }}>
{copy.intro}
</p>
{copy.sections.map((section, index) => (
<section key={section.title} style={SECTION_STYLE}>
<Tag color={index % 2 === 0 ? 'var(--amber)' : 'var(--violet, #a78bfa)'}>{section.tag}</Tag>
<h2 style={H2}>{section.title}</h2>
<p style={P}>
<strong>{isZh ? '直接结论:' : 'Direct answer:'}</strong> {section.answer}
</p>
{section.paragraphs.map(paragraph => (
<p key={paragraph} style={P}>{paragraph}</p>
))}
{section.formula && <Formula>{section.formula}</Formula>}
{section.bullets && (
<ul style={{ paddingLeft: 20 }}>
{section.bullets.map(item => <li key={item} style={LI}>{item}</li>)}
</ul>
)}
<p style={{ ...P, color: 'var(--ink-3)' }}>
<strong>{isZh ? '已知限制:' : 'Known limitation:'}</strong> {section.limitation}
</p>
</section>
))}
<div style={{ ...SECTION_STYLE, borderTop: '1px solid var(--line)' }}>
<p style={{ ...P, color: 'var(--ink-4)', fontSize: 12 }}>
{copy.footer}{' '}
<Link href={`/${locale}/contact`} style={{ color: 'var(--amber)' }}>{copy.footerLinks.contact}</Link>.
{' '}{isZh ? '另见:' : 'See also:'}{' '}
<Link href={`/${locale}/glossary`} style={{ color: 'var(--amber)' }}>{copy.footerLinks.glossary}</Link>
{' '}·{' '}
<Link href={`/${locale}/case-studies`} style={{ color: 'var(--amber)' }}>{copy.footerLinks.caseStudies}</Link>.
</p>
</div>
</div>
</div>
)
}
+46 -7
View File
@@ -1,18 +1,57 @@
import { getPosts, getPerformance } from '@/lib/api'
import { getPosts } from '@/lib/api'
import type { Metadata } from 'next'
import DashboardClient from './DashboardClient'
export const revalidate = 30
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
interface OverviewPageProps {
params: Promise<{ locale: string }>
}
export async function generateMetadata({ params }: OverviewPageProps): Promise<Metadata> {
const { locale } = await params
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const title = isZh
? 'Trump Alpha 信号总览'
: 'Trump Alpha Signal Monitor'
const description = isZh
? '实时查看 Trump Truth Social 信号、BTC 底部反转、KOL 观点和 talks-vs-trades 分歧,一页掌握核心加密信号。'
: 'Track Trump Truth Social signals, BTC bottom-reversal setups, KOL calls, and talks-vs-trades divergence in one live crypto dashboard.'
const path = `${siteUrl}/${locale}`
return {
title,
description,
alternates: {
canonical: path,
languages: {
en: `${siteUrl}/en`,
'x-default': `${siteUrl}/en`,
},
},
openGraph: {
title,
description,
url: path,
locale: isZh ? 'zh_CN' : 'en_US',
alternateLocale: isZh ? ['en_US'] : ['zh_CN'],
},
twitter: {
title,
description,
},
}
}
export default async function OverviewPage() {
const [posts, performance] = await Promise.allSettled([
getPosts(500, 1),
getPerformance(),
])
const posts = await getPosts(500, 1).catch(() => [])
return (
<DashboardClient
initialPosts={posts.status === 'fulfilled' ? posts.value : []}
initialPerformance={performance.status === 'fulfilled' ? performance.value : undefined}
initialPosts={posts}
/>
)
}
+12 -62
View File
@@ -1,64 +1,14 @@
'use client'
import { redirect } from 'next/navigation'
import { useState, useEffect } from 'react'
import type { TrumpPost } from '@/types'
import { getPosts } from '@/lib/api'
import PostRow from '@/components/dashboard/PostCards'
export default function PostsPage() {
const [posts, setPosts] = useState<TrumpPost[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('all')
useEffect(() => {
getPosts(200, 1).then(setPosts).catch(() => {}).finally(() => setLoading(false))
}, [])
const filtered = posts.filter(p => {
if (filter !== 'all' && p.sentiment !== filter) return false
return true
})
const counts = {
all: posts.length,
bullish: posts.filter(p => p.sentiment === 'bullish').length,
bearish: posts.filter(p => p.sentiment === 'bearish').length,
neutral: posts.filter(p => p.sentiment === 'neutral').length,
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Signals feed</h1>
<p className="page-sub">Every post we tracked, every prediction we made. {posts.length} posts in the last 30 days.</p>
</div>
<span className="chip"><span className="live-dot" />Streaming</span>
</div>
<div className="row between" style={{ marginBottom: 18 }}>
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{(['all', 'bullish', 'bearish', 'neutral'] as const).map(f => (
<button key={f} className={`nav-tab ${filter === f ? 'active' : ''}`} onClick={() => setFilter(f)}>
{f.charAt(0).toUpperCase() + f.slice(1)} <span style={{ color: 'var(--ink-4)', marginLeft: 4 }}>{counts[f]}</span>
</button>
))}
</div>
</div>
{loading && <div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>Loading</div>}
{!loading && filtered.length === 0 ? (
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
No posts match the current sentiment filter.
</div>
) : (
<div className="post-stream">
{filtered.map(p => (
<PostRow key={p.id} post={p} />
))}
</div>
)}
</div>
)
/**
* Legacy route. The old "Signals" hub was replaced by two top-level nav
* tabs (Trump / BTC). Anything still pointing here (old links, sitemap,
* bookmarks) lands on the Trump system page.
*/
export default function LegacySignalsRedirect({
params,
}: {
params: { locale: string }
}) {
redirect(`/${params.locale}/trump`)
}
+74 -42
View File
@@ -1,55 +1,87 @@
import Link from 'next/link'
import { getLocale } from 'next-intl/server'
export default async function PrivacyPage({ params: { locale } }: { params: { locale: string } }) {
const intlLocale = await getLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const sections = isZh
? [
{
title: '1. 我们收集什么',
body: '当你连接钱包时,我们会收集你的钱包地址,用于身份识别、订阅状态判断和机器人配置关联。除非你主动通过联系表单提供,我们不会收集姓名、邮箱等传统个人身份信息。',
},
{
title: '2. Hyperliquid API Key',
body: '如果你选择绑定 Hyperliquid API key,该密钥会以加密形式存储。明文不会被写入日志,也不会用于除 Hyperliquid 官方交易接口以外的用途。',
},
{
title: '3. 数据如何使用',
body: '钱包地址、订阅状态和交易配置只会用于执行你授权的机器人逻辑、展示个人交易表现,以及提供平台功能。我们不会出售、出租或用于广告投放。',
},
{
title: '4. 分析与统计',
body: '我们使用极简统计来理解页面访问、错误率和整体使用趋势。这些统计不会绑定你的真实身份,也不会用于跨站广告追踪。',
},
{
title: '5. 数据保留',
body: '只要你的账户仍在使用,相关订阅与配置数据会被保留。你可以联系我们请求删除。交易历史最多保留 24 个月,用于对账、争议处理和产品分析。',
},
{
title: '6. 联系方式',
body: '如果你有与隐私相关的问题,请使用联系页面与我们沟通。',
},
]
: [
{
title: '1. Information We Collect',
body: 'When you connect a wallet, we collect the wallet address required to identify your account, check subscription status, and link bot settings. We do not collect names, email addresses, or similar personal identifiers unless you voluntarily provide them through the contact form.',
},
{
title: '2. Hyperliquid API Keys',
body: 'If you connect a Hyperliquid API key, the key is stored in encrypted form. Plaintext keys are not written to logs and are only used for calls to official Hyperliquid trading endpoints.',
},
{
title: '3. How We Use Data',
body: 'Wallet addresses, subscription status, and trading settings are used only to run the bot logic you authorize, show your personal trading analytics, and operate product features. We do not sell, rent, or repurpose this data for advertising.',
},
{
title: '4. Analytics',
body: 'We use minimal analytics to understand page usage, error rates, and aggregate product patterns. These analytics are not tied to your real-world identity and are not used for cross-site advertising.',
},
{
title: '5. Data Retention',
body: 'Subscription and configuration data is retained while your account remains active. You may request deletion by contacting us. Trade-history records are retained for up to 24 months for reconciliation, dispute handling, and product analysis.',
},
{
title: '6. Contact',
body: 'If you have a privacy-related question, please use the contact page.',
},
]
export default function PrivacyPage({ params: { locale } }: { params: { locale: string } }) {
return (
<div className="page" style={{ maxWidth: 720 }}>
<div className="page-head">
<div>
<h1 className="page-title">Privacy Policy</h1>
<p className="page-sub">Last updated: April 2026</p>
<h1 className="page-title">{isZh ? '隐私政策' : 'Privacy Policy'}</h1>
<p className="page-sub">{isZh ? '最后更新:2026 年 4 月' : 'Last updated: April 2026'}</p>
</div>
</div>
<div className="card" style={{ padding: 32, lineHeight: 1.7, fontSize: 14, color: 'var(--ink-2)' }}>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>1. Information We Collect</h2>
<p style={{ marginBottom: 20 }}>
We collect your Ethereum wallet address when you connect your wallet. This address is used solely to
authenticate your session and manage your subscription. We do not collect your name, email address, or
any other personally identifiable information unless you voluntarily provide it via the contact form.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>2. Hyperliquid API Keys</h2>
<p style={{ marginBottom: 20 }}>
If you choose to connect a Hyperliquid API key, it is encrypted at rest using industry-standard symmetric
encryption (Fernet/AES-128-CBC). The plaintext key is never logged, cached, or transmitted except to
Hyperliquid&apos;s official API endpoints to execute trades on your behalf.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>3. How We Use Your Data</h2>
<p style={{ marginBottom: 20 }}>
Your wallet address and trading configuration are used exclusively to operate the automated trading bot
on your behalf. We do not sell, rent, or share your data with third parties, except as required to
process trades (Hyperliquid API) or comply with applicable law.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>4. Cookies &amp; Analytics</h2>
<p style={{ marginBottom: 20 }}>
We use minimal analytics to understand aggregate usage patterns (page views, error rates). No
personal identifiers are associated with analytics events. We do not use advertising cookies or
cross-site tracking.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>5. Data Retention</h2>
<p style={{ marginBottom: 20 }}>
Subscription data is retained for as long as your account is active. You may request deletion of your
data at any time by contacting us. Trade history is retained for up to 24 months for dispute resolution
purposes.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>6. Contact</h2>
<p>
For privacy-related inquiries, please use our <Link href={`/${locale}/contact`} style={{ color: 'var(--amber)' }}>contact form</Link>.
</p>
{sections.map((section, index) => (
<div key={section.title} style={{ marginBottom: index === sections.length - 1 ? 0 : 20 }}>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>{section.title}</h2>
<p style={{ marginBottom: 0 }}>
{section.title.endsWith('Contact') || section.title.endsWith('联系方式')
? <>
{section.body}{' '}
<Link href={`/${locale}/contact`} style={{ color: 'var(--amber)' }}>{isZh ? '联系页面' : 'contact page'}</Link>.
</>
: section.body}
</p>
</div>
))}
</div>
</div>
)
+58 -25
View File
@@ -1,48 +1,81 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useLocale } from 'next-intl'
import { useAccount } from 'wagmi'
import BotConfigPanel from '@/components/trades/BotConfigPanel'
import TelegramCard from '@/components/telegram/TelegramCard'
/**
* Settings page — the home for all configuration.
*
* Previously the BotConfigPanel lived on /trades (alongside the trade history),
* and /settings was just a redirect card. That made /trades double-duty
* (configure AND view results) and /settings feel like a dead end. We swap:
* - /settings → all configuration (bot, exchange key, subscription)
* - /trades → pure execution view (open positions + history)
* Legal links stay here since this is the "everything else" page.
*/
export default function SettingsClient() {
const localeIntl = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { address, isConnected } = useAccount()
const [mounted, setMounted] = useState(false)
const pathname = usePathname()
useEffect(() => { setMounted(true) }, [])
const locale = pathname.split('/')[1] || 'en'
const href = (path: string) => `/${locale}${path}`
return (
<div style={{ maxWidth: 560 }}>
{/* Account card */}
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
<div className="section-title" style={{ marginBottom: 16 }}><h2>Account</h2></div>
<div className="field">
<label>Connected wallet</label>
<input disabled value={isConnected && address ? `${address.slice(0, 10)}${address.slice(-8)}` : 'Not connected'} />
<div>
{/* Account card — quick "who am I logged in as" */}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 8 }}>
{isZh ? '账户' : 'Account'}
</div>
</div>
{/* Link to Trades page for bot config */}
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
<div className="row between" style={{ alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: mounted && isConnected ? 'var(--up-soft)' : 'var(--bg-sunk)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: mounted && isConnected ? 'var(--up)' : 'var(--ink-4)', fontSize: 14,
}}>
{mounted && isConnected ? '✓' : '○'}
</div>
<div>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Bot &amp; exchange settings</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)' }}>
Position size, leverage, TP/SL, Hyperliquid API key, and subscription are managed on the Trades page.
<div style={{ fontSize: 13, fontWeight: 500 }}>
{mounted && isConnected && address
? <span className="mono">{address.slice(0, 10)}{address.slice(-8)}</span>
: (isZh ? '未连接' : 'Not connected')}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)' }}>
{mounted && isConnected ? (isZh ? '钱包是下方所有配置的身份入口' : 'Wallet is the access key for everything below') : (isZh ? '连接钱包后才能配置机器人' : 'Connect a wallet to configure the bot')}
</div>
</div>
<Link href={href('/trades')} className="btn ghost" style={{ whiteSpace: 'nowrap', marginLeft: 16 }}>
Go to Trades
</Link>
</div>
</div>
{/* Legal links */}
<div className="card" style={{ padding: 24 }}>
<div className="section-title" style={{ marginBottom: 16 }}><h2>Legal</h2></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Link href={href('/privacy')} style={{ fontSize: 14, color: 'var(--ink-2)', textDecoration: 'none' }}>Privacy Policy </Link>
<Link href={href('/terms')} style={{ fontSize: 14, color: 'var(--ink-2)', textDecoration: 'none' }}>Terms of Service </Link>
<Link href={href('/contact')} style={{ fontSize: 14, color: 'var(--ink-2)', textDecoration: 'none' }}>Contact Us </Link>
{/* The full bot config UI (subscribe, HL key, risk settings, schedule) */}
<BotConfigPanel />
{/* Telegram push alerts — only renders something useful when wallet
connected + server configured + (eventually) subscribed. */}
<TelegramCard />
{/* Legal & support */}
<div className="card" style={{ padding: 20 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 12 }}>
{isZh ? '法律与支持' : 'Legal & support'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Link href={href('/privacy')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '隐私政策 →' : 'Privacy Policy →'}</Link>
<Link href={href('/terms')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '服务条款 →' : 'Terms of Service →'}</Link>
<Link href={href('/contact')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '联系我们 →' : 'Contact Us →'}</Link>
</div>
</div>
</div>
+28 -3
View File
@@ -1,12 +1,37 @@
import type { Metadata } from 'next'
import { getLocale } from 'next-intl/server'
import SettingsClient from './SettingsClient'
export default function SettingsPage() {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const title = isZh ? '机器人设置与订阅' : 'Bot Settings & Subscription'
const description = isZh
? '配置订阅状态、Hyperliquid API、风控参数、自动交易模式和提醒渠道。这里决定 Trump Alpha 如何代表你执行。'
: 'Configure subscription state, Hyperliquid API access, risk controls, auto-trade mode, and alert channels. This page defines how Trump Alpha executes for you.'
return {
title,
description,
alternates: {
canonical: `${siteUrl}/${locale}/settings`,
languages: {
en: `${siteUrl}/en/settings`,
},
},
}
}
export default async function SettingsPage() {
const locale = await getLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Settings</h1>
<p className="page-sub">Configure your bot, alerts, and account.</p>
<h1 className="page-title">{isZh ? '设置' : 'Settings'}</h1>
<p className="page-sub">{isZh ? '订阅、交易所密钥、风险限制和运行时间安排,都会在这里配置。' : 'Subscription, exchange key, risk limits, and schedule — everything that decides how the bot trades.'}</p>
</div>
</div>
<SettingsClient />
+80 -50
View File
@@ -1,61 +1,91 @@
export default function TermsPage() {
import { getLocale } from 'next-intl/server'
export default async function TermsPage() {
const locale = await getLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const sections = isZh
? [
{
title: '1. 接受条款',
body: '当你连接钱包或使用 Trump Alpha 时,就表示你接受这些服务条款。如果你不同意,请不要继续使用。',
},
{
title: '2. 非投资建议',
body: 'Trump Alpha 是研究与自动化工具,不是财务顾问。所有信号、AI 分析和自动交易能力都只提供信息与执行工具,不构成投资建议。',
},
{
title: '3. 风险披露',
body: '加密货币和杠杆交易都伴随显著风险,损失可能超过你的初始投入。开启自动交易前,你需要自行理解并接受这些风险。',
},
{
title: '4. 允许用途',
body: '你可以将 Trump Alpha 用于个人、非商业目的。未经许可,不得反向工程、批量抓取、转售或重新分发平台上的数据与信号。',
},
{
title: '5. 服务可用性',
body: '我们会尽量保持高可用,但不保证平台永远不中断。计划维护、基础设施故障或第三方 API 异常都可能影响服务。',
},
{
title: '6. 条款更新',
body: '我们可能会在未来更新这些条款。更新后继续使用服务,即视为你接受新版本条款。',
},
{
title: '7. 责任限制',
body: '在法律允许的最大范围内,Trump Alpha 及其运营方不对因使用本服务而产生的间接损失、附带损失或交易损失承担责任。',
},
]
: [
{
title: '1. Acceptance',
body: 'By connecting a wallet or using Trump Alpha, you accept these Terms of Service. If you do not agree, do not use the product.',
},
{
title: '2. Not Financial Advice',
body: 'Trump Alpha is a research and automation tool, not a financial advisor. All signals, AI analysis, and auto-trading capabilities are provided as information and execution tooling, not as investment advice.',
},
{
title: '3. Risk Disclosure',
body: 'Crypto trading and leverage both involve significant risk, including the possibility of losses greater than your initial capital. You are responsible for understanding and accepting those risks before enabling automation.',
},
{
title: '4. Permitted Use',
body: 'You may use Trump Alpha for personal, non-commercial purposes. You may not reverse-engineer, bulk scrape, resell, or redistribute platform data or signals without permission.',
},
{
title: '5. Service Availability',
body: 'We aim for high availability, but we do not guarantee uninterrupted service. Maintenance, infrastructure issues, or third-party API outages may affect the platform.',
},
{
title: '6. Modifications',
body: 'We may update these terms in the future. Continued use of the service after changes means you accept the updated version.',
},
{
title: '7. Limitation of Liability',
body: 'To the maximum extent permitted by law, Trump Alpha and its operators are not liable for indirect damages, incidental damages, or trading losses arising from use of the service.',
},
]
return (
<div className="page" style={{ maxWidth: 720 }}>
<div className="page-head">
<div>
<h1 className="page-title">Terms of Service</h1>
<p className="page-sub">Last updated: April 2026</p>
<h1 className="page-title">{isZh ? '服务条款' : 'Terms of Service'}</h1>
<p className="page-sub">{isZh ? '最后更新:2026 年 4 月' : 'Last updated: April 2026'}</p>
</div>
</div>
<div className="card" style={{ padding: 32, lineHeight: 1.7, fontSize: 14, color: 'var(--ink-2)' }}>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>1. Acceptance</h2>
<p style={{ marginBottom: 20 }}>
By connecting your wallet or using TrumpSignal, you agree to these Terms of Service. If you do not
agree, do not use the service.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>2. Not Financial Advice</h2>
<p style={{ marginBottom: 20 }}>
<strong style={{ color: 'var(--ink)' }}>TrumpSignal is a research and automation tool, not a financial advisor.</strong>{' '}
All signals, AI analysis, and automated trades are provided for informational purposes only and do not
constitute investment advice. Past performance is not indicative of future results. You are solely
responsible for your trading decisions and any losses incurred.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>3. Risk Disclosure</h2>
<p style={{ marginBottom: 20 }}>
Cryptocurrency trading involves significant risk of loss. Leveraged trading can result in losses
exceeding your initial investment. By enabling the automated bot, you acknowledge these risks and
accept full responsibility for all trades executed on your behalf.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>4. Permitted Use</h2>
<p style={{ marginBottom: 20 }}>
You may use TrumpSignal for personal, non-commercial purposes. You may not reverse-engineer, scrape,
resell, or redistribute any data or signals from the platform. You are responsible for ensuring your
use complies with applicable laws in your jurisdiction.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>5. Service Availability</h2>
<p style={{ marginBottom: 20 }}>
We strive for high availability but make no guarantee of uptime. Scheduled maintenance, infrastructure
failures, or third-party API outages may interrupt service. We are not liable for missed trades or
losses resulting from service interruptions.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>6. Modifications</h2>
<p style={{ marginBottom: 20 }}>
We may update these Terms at any time. Continued use of the service after changes constitutes
acceptance of the updated Terms.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>7. Limitation of Liability</h2>
<p>
To the maximum extent permitted by law, TrumpSignal and its operators shall not be liable for any
indirect, incidental, or consequential damages arising from your use of the service, including trading
losses.
</p>
{sections.map((section, index) => (
<div key={section.title} style={{ marginBottom: index === sections.length - 1 ? 0 : 20 }}>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>{section.title}</h2>
<p style={{ marginBottom: 0 }}>
{section.title.includes('Not Financial Advice') || section.title.includes('非投资建议')
? <><strong style={{ color: 'var(--ink)' }}>{section.body}</strong></>
: section.body}
</p>
</div>
))}
</div>
</div>
)
+116
View File
@@ -0,0 +1,116 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import type { BotTrade, TrumpPost } from '@/types'
import { getTrades, getPosts } from '@/lib/api'
import { useDashboardStore } from '@/store/dashboard'
import { getCachedViewEnvelope, getOrCreateViewEnvelope } from '@/lib/signedRequest'
import TradeTable from '@/components/trades/TradeTable'
import OpenPositions from '@/components/positions/OpenPositions'
export default function TradesPageClient() {
const intlLocale = 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 { isSubscribed, hlApiKeySet } = useDashboardStore()
const pathname = usePathname()
const locale = pathname.split('/')[1] || 'en'
const [mounted, setMounted] = useState(false)
const [trades, setTrades] = useState<BotTrade[]>([])
const [posts, setPosts] = useState<TrumpPost[]>([])
const [loading, setLoading] = useState(true)
const [loadErr, setLoadErr] = useState('')
useEffect(() => { setMounted(true) }, [])
useEffect(() => {
let cancelled = false
if (!address || !isConnected) {
setTrades([])
setPosts([])
setLoading(false)
setLoadErr('')
return
}
setLoading(true)
;(async () => {
let failed = false
try {
const env = getCachedViewEnvelope('view_trades', address)
?? await getOrCreateViewEnvelope({ action: 'view_trades', wallet: address, signMessageAsync })
const [t, p] = await Promise.all([
getTrades(address, env, 100, 1).catch(e => {
failed = true
setLoadErr(e instanceof Error ? e.message : (isZh ? '交易加载失败' : 'Failed to load trades'))
return [] as BotTrade[]
}),
getPosts(500, 1).catch(() => [] as TrumpPost[]),
])
if (!cancelled) {
setTrades(t)
setPosts(p)
if (!failed) setLoadErr('')
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => { cancelled = true }
}, [address, isConnected, signMessageAsync, isZh])
const needsSetup = mounted && isConnected && (!isSubscribed || !hlApiKeySet)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '交易执行' : 'Trades'}</h1>
<p className="page-sub">{isZh ? '当前持仓、执行历史和按信号来源拆分的盈亏。' : 'Open positions · execution history · P&L by source'}</p>
</div>
</div>
{needsSetup && (
<div className="card" style={{
padding: '14px 18px', marginBottom: 16,
background: 'var(--amber-soft)',
borderColor: 'color-mix(in oklab, var(--amber) 22%, var(--line))',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
flexWrap: 'wrap', gap: 12,
}}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{isZh ? '机器人尚未配置完成' : 'Bot not configured'}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>
{isZh ? '在机器人开始交易前,请先去设置页订阅并绑定 Hyperliquid API。' : 'Subscribe and link your Hyperliquid API wallet on the Settings page before the bot can trade.'}
</div>
</div>
<Link
href={`/${locale}/settings`}
className="btn amber"
style={{ padding: '8px 16px', fontSize: 13, textDecoration: 'none' }}
>
{isZh ? '前往设置 →' : 'Go to Settings →'}
</Link>
</div>
)}
<OpenPositions />
{!loading && loadErr && (
<div className="card" style={{ padding: 16, margin: '12px 0', textAlign: 'center',
color: 'var(--down)', fontSize: 13 }}>
{isZh ? `无法加载交易历史:${loadErr}` : `Couldnt load trade history — ${loadErr}`}
<button className="btn ghost" style={{ fontSize: 12, padding: '5px 12px', marginLeft: 10 }}
onClick={() => location.reload()}>{isZh ? '重试' : 'Retry'}</button>
</div>
)}
<TradeTable trades={trades} posts={posts} loading={loading} />
</div>
)
}
+22 -785
View File
@@ -1,791 +1,28 @@
'use client'
import type { Metadata } from 'next'
import { getLocale } from 'next-intl/server'
import TradesPageClient from './TradesPageClient'
import { useState, useEffect } from 'react'
import { useAccount, useConnect, useSignMessage } from 'wagmi'
import type { BotTrade, TrumpPost } from '@/types'
import { getTrades, getPosts, getUserPublic, getUser, setUserSettings, setHlApiKey, subscribe, type UserSettings } from '@/lib/api'
import { useDashboardStore } from '@/store/dashboard'
import { signRequest, getOrCreateViewEnvelope, getCachedViewEnvelope } from '@/lib/signedRequest'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
// ─── helpers ──────────────────────────────────────────────────────────────────
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'
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const title = isZh ? '交易执行与持仓' : 'Trades & Open Positions'
const description = isZh
? '查看当前持仓、历史成交、按信号来源拆分的盈亏,以及机器人是否已经具备真实执行条件。'
: 'Review open positions, historical executions, source-level P&L, and whether the bot is ready for live trading.'
return {
title,
description,
alternates: {
canonical: `${siteUrl}/${locale}/trades`,
languages: {
en: `${siteUrl}/en/trades`,
},
},
}
}
// Defaults shown to a brand-new, un-configured wallet. take_profit_pct,
// stop_loss_pct and daily_budget_usd are all REQUIRED before the bot will
// trade — we still initialise them so the inputs have sensible placeholders.
const DEFAULT_SETTINGS: UserSettings = {
leverage: 3, position_size_usd: 20, take_profit_pct: 2, stop_loss_pct: 1.5,
min_confidence: 70, daily_budget_usd: 15,
active_from: null, active_until: null,
}
// ── datetime-local helpers (browser local tz ↔ ISO UTC) ──
function isoToLocalInput(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function localInputToIso(v: string): string | null {
if (!v) return null
const d = new Date(v) // interpreted as local time
if (isNaN(d.getTime())) return null
return d.toISOString()
}
// ─── Bot config panel ─────────────────────────────────────────────────────────
function BotConfigPanel() {
const { address, isConnected } = useAccount()
const { connect, connectors } = useConnect()
const { signMessageAsync } = useSignMessage()
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness, setSubscribed, setBotReadiness, setHlApiKeySet } = useDashboardStore()
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
// Tracks whether the SERVER already has values for these required fields.
// Used to flag "setup incomplete" until the user saves for the first time.
const [tpConfigured, setTpConfigured] = useState(false)
const [slConfigured, setSlConfigured] = useState(false)
const [budgetConfigured, setBudgetConfigured] = useState(false)
const [useSchedule, setUseSchedule] = useState(false)
const [fromLocal, setFromLocal] = useState('')
const [untilLocal, setUntilLocal] = useState('')
const [dirty, setDirty] = useState(false)
const [saveState, setSaveState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle')
const [saveErr, setSaveErr] = useState('')
// HL key state
const [apiKey, setApiKey] = useState('')
const [keyState, setKeyState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle')
const [keyErr, setKeyErr] = useState('')
// Subscribe state
const [subState, setSubState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
const [subErr, setSubErr] = useState('')
// Mounted flag: avoid SSR/CSR mismatch since wagmi state differs between the two.
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
// Settings-load state ("idle" | "loading" | "loaded" | "err"). We do NOT
// auto-pop MetaMask — only fetch with a cached signature; otherwise wait
// for the user to click "Load settings".
const [loadState, setLoadState] = useState<'idle'|'loading'|'loaded'|'err'>('idle')
const [loadErr, setLoadErr] = useState('')
// Apply a fetched user payload into local form state.
function applyUserPayload(u: { active: boolean; hl_api_key_set: boolean; hl_api_key_masked: string | null; settings: UserSettings }) {
setSubscribed(u.active)
setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined)
if (u.settings) {
// Fall back to defaults when server returns null so the inputs are
// still usable, but remember which required fields weren't configured
// so we can show the "Setup incomplete" warning.
const s = u.settings
setTpConfigured(s.take_profit_pct != null)
setSlConfigured(s.stop_loss_pct != null)
setBudgetConfigured(s.daily_budget_usd != null)
setBotReadiness(u.hl_api_key_set ? 'saved' : 'unknown')
setSettings({
...s,
take_profit_pct: s.take_profit_pct ?? 2,
stop_loss_pct: s.stop_loss_pct ?? 1.5,
daily_budget_usd: s.daily_budget_usd ?? 15,
})
const hasSched = s.active_from != null && s.active_until != null
setUseSchedule(hasSched)
setFromLocal(isoToLocalInput(s.active_from))
setUntilLocal(isoToLocalInput(s.active_until))
}
}
// On connect: fetch public state only (no signature). If a fresh view
// envelope is already cached in sessionStorage, silently hydrate full data.
useEffect(() => {
if (!mounted || !isConnected || !address) return
let cancelled = false
;(async () => {
const pub = await getUserPublic(address.toLowerCase()).catch(() => null)
if (!pub || cancelled) return
setSubscribed(pub.active)
setHlApiKeySet(pub.hl_api_key_set)
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
if (!pub.active) return
// Silent rehydrate: only if an unexpired cached envelope exists.
const cached = getCachedViewEnvelope('view_user', address)
if (!cached) return
const u = await getUser(address, cached).catch(() => null)
if (!u || cancelled) return
applyUserPayload(u)
setLoadState('loaded')
})()
return () => { cancelled = true }
}, [address, isConnected, mounted])
// Explicit "Load settings" — the only place that may pop MetaMask.
async function handleLoadSettings() {
if (!address) return
setLoadErr(''); setLoadState('loading')
try {
const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync })
const u = await getUser(address, env)
applyUserPayload(u)
setLoadState('loaded')
} catch (e: unknown) {
const m = e instanceof Error ? e.message : ''
setLoadErr(m.includes('rejected') || m.includes('denied') ? 'Cancelled' : m.slice(0, 120))
setLoadState('err')
}
}
async function refreshUserState(wallet: string) {
const pub = await getUserPublic(wallet.toLowerCase())
setSubscribed(pub.active)
setHlApiKeySet(pub.hl_api_key_set)
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
return pub
}
function updateSettings(patch: Partial<UserSettings>) {
setSettings(s => ({ ...s, ...patch }))
setDirty(true)
}
async function handleSubscribe() {
if (!address) return
setSubErr(''); setSubState('signing')
try {
const env = await signRequest({ action: 'subscribe', wallet: address, body: null, signMessageAsync })
setSubState('saving')
await subscribe(env)
setSubscribed(true)
void refreshUserState(address)
setSubState('idle')
// Fresh subscribers have no settings/trades/api-key yet — skip the
// view_user fetch entirely so they don't have to sign a SECOND popup
// immediately after subscribing. The defaults in local state are fine;
// we mark loadState='loaded' so the UI shows the configuration form.
setTpConfigured(false)
setSlConfigured(false)
setBudgetConfigured(false)
setLoadState('loaded')
} catch (e: unknown) {
const m = e instanceof Error ? e.message : ''
setSubErr(m.includes('rejected') || m.includes('denied') ? 'Cancelled' : m.slice(0, 100))
setSubState('err')
}
}
async function handleSaveSettings() {
if (!address) return
setSaveErr(''); setSaveState('signing')
try {
let schedFrom: string | null = null
let schedUntil: string | null = null
if (useSchedule) {
schedFrom = localInputToIso(fromLocal)
schedUntil = localInputToIso(untilLocal)
if (!schedFrom || !schedUntil) {
setSaveErr('Pick both a start and end time'); setSaveState('err'); return
}
if (new Date(schedUntil).getTime() <= new Date(schedFrom).getTime()) {
setSaveErr('End must be after start'); setSaveState('err'); return
}
}
// TP, SL and daily budget are mandatory; enforce here before signing.
const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd
if (tp == null || tp < 0.1 || tp > 50) { setSaveErr('Take profit must be 0.1 50%'); setSaveState('err'); return }
if (sl == null || sl < 0.1 || sl > 50) { setSaveErr('Stop loss must be 0.1 50%'); setSaveState('err'); return }
if (bd == null || bd <= 0 || bd > 100000) { setSaveErr('Daily budget must be > $0'); setSaveState('err'); return }
const payload: UserSettings = {
...settings,
take_profit_pct: tp,
stop_loss_pct: sl,
daily_budget_usd: bd,
active_from: schedFrom,
active_until: schedUntil,
}
const env = await signRequest({ action: 'set_settings', wallet: address, body: payload, signMessageAsync })
setSaveState('saving')
await setUserSettings(env, payload)
setSettings(payload)
setTpConfigured(true); setSlConfigured(true); setBudgetConfigured(true)
setBotReadiness('saved')
setDirty(false)
setSaveState('ok')
setTimeout(() => setSaveState('idle'), 2000)
} catch (e: unknown) {
const m = e instanceof Error ? e.message : ''
setSaveErr(m.includes('rejected') ? 'Cancelled' : m.slice(0, 100))
setSaveState('err')
}
}
async function handleSaveKey() {
if (!address || !apiKey.trim()) return
const trimmed = apiKey.trim()
if (!trimmed.startsWith('0x') || trimmed.length !== 66) {
setKeyErr('Must be 0x + 64 hex chars'); setKeyState('err'); return
}
setKeyErr(''); setKeyState('signing')
try {
const env = await signRequest({ action: 'set_hl_api_key', wallet: address, body: { api_key: trimmed }, signMessageAsync })
setKeyState('saving')
const res = await setHlApiKey(env, trimmed)
setHlApiKeySet(true, res.masked_key)
setBotReadiness('saved')
void refreshUserState(address)
setApiKey('')
setKeyState('ok')
} catch (e: unknown) {
const m = e instanceof Error ? e.message : ''
setKeyErr(m.includes('rejected') ? 'Cancelled' : m.slice(0, 100))
setKeyState('err')
}
}
function handleConnectWallet() {
const connector = connectors[0]
if (connector) connect({ connector })
}
// Before client mount, render a stable placeholder so SSR output matches
// the first client paint (avoids hydration mismatch — wagmi state differs).
if (!mounted) {
return <div className="card" style={{ padding: 32, marginBottom: 28, minHeight: 120 }} />
}
if (!isConnected) {
return (
<div className="card" style={{ padding: 32, textAlign: 'center', marginBottom: 28 }}>
<p style={{ color: 'var(--ink-3)', marginBottom: 16 }}>Connect your wallet to configure the bot and view your trades.</p>
<button className="btn amber" onClick={handleConnectWallet}>
Connect wallet
</button>
</div>
)
}
// ── small local helpers ──
const Switch = ({ on, onChange, tone = 'amber' }: { on: boolean; onChange: (v: boolean) => void; tone?: 'amber' | 'up' | 'down' }) => (
<label className={`switch ${tone === 'amber' ? '' : tone}`}>
<input type="checkbox" checked={on} onChange={e => { onChange(e.target.checked); setDirty(true) }} />
<span className="switch-track" />
</label>
)
const saveBtnLabel =
saveState === 'signing' ? 'Sign in wallet…'
: saveState === 'saving' ? 'Saving…'
: saveState === 'ok' ? '✓ Saved'
: 'Save changes'
const keyHint =
hlApiKeySet && !apiKey
? 'Saved in your bot profile. If you rotate the API wallet in Hyperliquid, update it here before trading again.'
: 'Use the Hyperliquid API wallet private key from app.hyperliquid.xyz/API. Never paste your MetaMask secret phrase or private key here.'
const keyStatusHint =
keyState === 'ok'
? 'API wallet saved. Recommended next step: keep size tiny and run your own first BTC test before treating the setup as production-ready.'
: null
// ── Hyperliquid key strip (shown above the settings card) ──
const hlKeyStrip = (
<div className="card" style={{ padding: '16px 20px', marginBottom: 16, display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: hlApiKeySet ? 'var(--up-soft)' : 'var(--bg-sunk)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, color: hlApiKeySet ? 'var(--up)' : 'var(--ink-4)' }}>
{hlApiKeySet ? '✓' : '◇'}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Hyperliquid API wallet</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 1 }}>
{!isSubscribed ? 'Subscribe first to link your HL account'
: hlApiKeySet && !apiKey ? <>Linked · <span className="mono">{hlApiKeyMasked ?? '···'}</span> · trade-only scope</>
: 'Paste your API wallet private key — trade-only, never withdraw'}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isSubscribed && hlApiKeySet && !apiKey && (
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }} onClick={() => setApiKey(' ')}>Rotate</button>
)}
{isSubscribed && (!hlApiKeySet || apiKey) && (
<>
<input value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
onChange={e => { setApiKey(e.target.value); if (keyState !== 'idle') setKeyState('idle') }}
placeholder="0x…"
style={{ width: 220, fontFamily: 'var(--mono)', fontSize: 12, padding: '8px 10px' }} />
<button className={`btn ${keyState === 'ok' ? 'ghost' : 'amber'}`} style={{ padding: '8px 14px', fontSize: 12 }}
onClick={handleSaveKey} disabled={keyState === 'signing' || keyState === 'saving' || !apiKey.trim()}>
{keyState === 'signing' ? 'Sign…' : keyState === 'saving' ? 'Saving…' : keyState === 'ok' ? '✓' : 'Save'}
</button>
</>
)}
</div>
<div style={{ gridColumn: '1 / -1', fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.55 }}>
{keyHint}
</div>
{keyStatusHint && (
<div style={{ gridColumn: '1 / -1', fontSize: 11, color: 'var(--up)', lineHeight: 1.55 }}>
{keyStatusHint}
</div>
)}
{keyState === 'err' && (
<div style={{ gridColumn: '1 / -1', fontSize: 12, color: 'var(--down)' }}>{keyErr}</div>
)}
</div>
)
// ── Is the account fully configured? Bot only trades when every box is ticked. ──
const missingSetup: string[] = []
if (!isSubscribed) missingSetup.push('subscribe to the bot')
if (!hlApiKeySet) missingSetup.push('link a Hyperliquid API wallet')
if (!tpConfigured) missingSetup.push('take-profit')
if (!slConfigured) missingSetup.push('stop-loss')
if (!budgetConfigured) missingSetup.push('daily budget')
const botReady = missingSetup.length === 0
const setupSteps = [
{ label: '1. Subscribe', done: isSubscribed },
{ label: '2. Add HL API wallet', done: hlApiKeySet },
{ label: '3. Save risk limits', done: tpConfigured && slConfigured && budgetConfigured },
]
const setupGuideMessage =
!isSubscribed ? 'Start by subscribing with the connected wallet. That unlocks the bot profile for this address.'
: botReadiness === 'ready' ? 'Your setup is verified. Before relying on automation, run one tiny BTC trade with your own wallet and Hyperliquid API wallet to confirm the live path end to end.'
: !hlApiKeySet ? 'Next, paste the Hyperliquid API wallet private key you generated inside Hyperliquid. Do not paste your MetaMask private key or seed phrase here.'
: 'Your exchange key is saved. Finish take-profit, stop-loss, and daily budget, then save changes. The backend still needs to verify this setup before we mark it ready.'
return (
<div style={{ marginBottom: 28 }}>
<div
className="card"
style={{
padding: '14px 18px',
marginBottom: 12,
background: botReady ? 'var(--up-soft)' : 'var(--amber-soft)',
borderColor: botReady ? 'color-mix(in oklab, var(--up) 18%, var(--line))' : 'color-mix(in oklab, var(--amber) 22%, var(--line))',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }}>
{setupSteps.map((step) => (
<span
key={step.label}
className={`chip ${step.done ? 'up' : ''}`}
style={{ fontSize: 11 }}
>
{step.done ? '✓ ' : '○ '}
{step.label}
</span>
))}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.55 }}>
{setupGuideMessage}
</div>
</div>
{hlKeyStrip}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* ─── Header bar ─── */}
<div style={{ padding: '20px 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid var(--line)' }}>
<div>
<div style={{ fontSize: 16, fontWeight: 600, letterSpacing: '-0.01em' }}>Trading bot</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>Executes on Hyperliquid when a Trump post clears your filters.</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{dirty && isSubscribed && <span style={{ fontSize: 12, color: 'var(--amber-ink)' }}> Unsaved</span>}
{saveState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{saveErr}</span>}
{!isSubscribed ? (
<button className="btn amber" style={{ padding: '8px 18px', fontSize: 13 }}
onClick={handleSubscribe} disabled={subState === 'signing' || subState === 'saving'}>
{subState === 'signing' ? 'Sign…' : subState === 'saving' ? 'Activating…' : 'Subscribe'}
</button>
) : (
<>
<span className={`chip ${botReady ? 'up' : 'down'}`} style={{ fontSize: 11 }}>
{botReady ? '● Bot ready' : '● Bot paused'}
</span>
<button className={`btn ${saveState === 'ok' ? 'ghost' : 'amber'}`}
style={{ padding: '8px 18px', fontSize: 13 }}
onClick={handleSaveSettings}
disabled={!dirty || saveState === 'signing' || saveState === 'saving'}>
{saveBtnLabel}
</button>
</>
)}
</div>
</div>
{/* ─── Setup-incomplete warning (persists until fully configured) ─── */}
{isSubscribed && loadState === 'loaded' && !botReady && (
<div style={{
padding: '14px 24px',
background: 'var(--down-soft)',
borderBottom: '1px solid var(--line)',
display: 'flex', alignItems: 'flex-start', gap: 12,
}}>
<span style={{ fontSize: 16, color: 'var(--down)', lineHeight: 1 }}></span>
<div style={{ fontSize: 13, lineHeight: 1.55, color: 'var(--ink)' }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>Setup incomplete the bot will not open any trades.</div>
<div style={{ color: 'var(--ink-3)' }}>
Finish configuring: <span style={{ color: 'var(--down)', fontWeight: 500 }}>{missingSetup.join(' · ')}</span>. Fill every required field below and save. The bot stays paused until every box is ticked.
</div>
</div>
</div>
)}
{isSubscribed && loadState === 'loaded' && botReady && (
<div style={{
padding: '14px 24px',
background: 'var(--up-soft)',
borderBottom: '1px solid var(--line)',
display: 'flex', alignItems: 'flex-start', gap: 12,
}}>
<span style={{ fontSize: 16, color: 'var(--up)', lineHeight: 1 }}></span>
<div style={{ fontSize: 13, lineHeight: 1.55, color: 'var(--ink)' }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>Setup complete on this page.</div>
<div style={{ color: 'var(--ink-3)' }}>
Wallet subscription, API wallet storage, and risk fields are all saved. Before you rely on automation, use your own account to run one tiny BTC test and confirm the live trade path behaves the way you expect.
</div>
</div>
</div>
)}
{!isSubscribed ? (
<div style={{ padding: 28, color: 'var(--ink-3)', fontSize: 13, lineHeight: 1.65 }}>
Subscribe to activate automated trading. The bot signs positions on Hyperliquid using an API wallet scoped to trade-only it can never withdraw. You pick the size, leverage, risk limits, and signal threshold below.
{subState === 'err' && <div style={{ marginTop: 10, color: 'var(--down)' }}>{subErr}</div>}
</div>
) : loadState !== 'loaded' ? (
<div style={{ padding: 28 }}>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 16 }}>
Sign a one-time message with your wallet to load your bot settings and trade history. The signature is cached for 4 minutes so repeat visits won't prompt again.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button className="btn amber" onClick={handleLoadSettings}
disabled={loadState === 'loading'}>
{loadState === 'loading' ? 'Waiting for signature' : 'Load my settings'}
</button>
{loadState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{loadErr}</span>}
</div>
</div>
) : (
<div style={{ padding: '8px 24px 24px' }}>
{/* ─── EXECUTION ─── */}
<div className="section-head">
<span className="section-head-label">Execution</span>
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
<div className="form-row">
<div className="form-row-label">
Per-trade size
<span className="hint">Notional in USD. margin ≈ ${(settings.position_size_usd / settings.leverage).toFixed(2)} at {settings.leverage}×</span>
</div>
<div className="form-row-control">
<div className="num-field">
<span className="prefix">$</span>
<input type="number" min={5} max={10000} step={5} value={settings.position_size_usd}
onChange={e => updateSettings({ position_size_usd: Math.max(5, +e.target.value || 0) })} />
</div>
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>$5 $10,000</span>
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Leverage
<span className="hint">Applied to every position</span>
</div>
<div className="form-row-control">
<div className="slider-field">
<input type="range" min={1} max={50} value={settings.leverage}
onChange={e => updateSettings({ leverage: +e.target.value })} />
<div className="ticks"><span>1×</span><span>25×</span><span>50×</span></div>
</div>
<span className="slider-readout">{settings.leverage}×</span>
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Min AI confidence
<span className="hint">Skip any signal below this score (0100)</span>
</div>
<div className="form-row-control">
<div className="slider-field">
<input type="range" min={0} max={100} value={settings.min_confidence}
onChange={e => updateSettings({ min_confidence: +e.target.value })} />
<div className="ticks"><span>0</span><span>50</span><span>100</span></div>
</div>
<span className="slider-readout">{settings.min_confidence}%</span>
</div>
</div>
{/* ─── LIMITS ─── */}
<div className="section-head" style={{ marginTop: 20 }}>
<span className="section-head-label">Limits &amp; risk</span>
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
<div className="form-row">
<div className="form-row-label">
Daily budget <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Stop opening new trades once the day's total notional reaches this (UTC). Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
<span className="prefix">$</span>
<input type="number" min={1} max={100000} step={5}
value={settings.daily_budget_usd ?? ''}
onChange={e => updateSettings({ daily_budget_usd: e.target.value === '' ? null : Math.max(0, +e.target.value) })} />
<span className="suffix">/day</span>
</div>
{!budgetConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Take profit <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when unrealised gain hits target. Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
<input type="number" min={0.1} max={50} step={0.1}
value={settings.take_profit_pct ?? ''}
onChange={e => updateSettings({ take_profit_pct: e.target.value === '' ? null : +e.target.value })} />
<span className="suffix">% gain</span>
</div>
{!tpConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when drawdown hits limit. Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
<input type="number" min={0.1} max={50} step={0.1}
value={settings.stop_loss_pct ?? ''}
onChange={e => updateSettings({ stop_loss_pct: e.target.value === '' ? null : +e.target.value })} />
<span className="suffix">% loss</span>
</div>
{!slConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
</div>
</div>
{/* ─── SCHEDULE ─── */}
<div className="section-head" style={{ marginTop: 20 }}>
<span className="section-head-label">Active schedule</span>
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
<div className="form-row">
<div className="form-row-label">
Only run in a window
<span className="hint">Bot ignores signals outside this range. Shown in your browser's local time.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap' }}>
<Switch on={useSchedule} onChange={(v) => { setUseSchedule(v); setDirty(true) }} />
{useSchedule && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div className="num-field">
<span className="prefix">From</span>
<input type="datetime-local" value={fromLocal}
onChange={e => { setFromLocal(e.target.value); setDirty(true) }}
style={{ width: 190 }} />
</div>
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>→</span>
<div className="num-field">
<span className="prefix">Until</span>
<input type="datetime-local" value={untilLocal}
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }}
style={{ width: 190 }} />
</div>
</div>
)}
</div>
</div>
{useSchedule && fromLocal && untilLocal && (() => {
const fromMs = new Date(fromLocal).getTime()
const untilMs = new Date(untilLocal).getTime()
const nowMs = Date.now()
if (isNaN(fromMs) || isNaN(untilMs) || untilMs <= fromMs) return null
const inWindow = nowMs >= fromMs && nowMs < untilMs
const durMs = untilMs - fromMs
const durH = durMs / 3600000
const durLabel = durH < 1 ? `${Math.round(durH * 60)} min`
: durH < 48 ? `${durH.toFixed(1)} h`
: `${(durH / 24).toFixed(1)} d`
return (
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 24, padding: '0 0 14px' }}>
<div />
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 12 }}>
<span className={`chip ${inWindow ? 'up' : ''}`} style={{ fontSize: 11 }}>
{inWindow ? ' In window now' : ' Outside window'}
</span>
<span style={{ color: 'var(--ink-4)' }}>Duration: {durLabel}</span>
</div>
</div>
)
})()}
</div>
)}
</div>
</div>
)
}
// ─── Trades table ─────────────────────────────────────────────────────────────
export default function TradesPage() {
const [trades, setTrades] = useState<BotTrade[]>([])
const [posts, setPosts] = useState<TrumpPost[]>([])
const [loading, setLoading] = useState(true)
const [assetFilter, setAssetFilter] = useState('all')
const [sideFilter, setSideFilter] = useState('all')
useEffect(() => {
Promise.all([
getTrades(100, 1).catch(() => []),
getPosts(500, 1).catch(() => []),
]).then(([t, p]) => { setTrades(t); setPosts(p) })
.finally(() => setLoading(false))
}, [])
const filtered = trades.filter(t => {
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
if (sideFilter !== 'all' && t.side !== sideFilter) return false
return true
})
// Trades closed externally on HL may have null pnl_usd — exclude 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 (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Trades</h1>
<p className="page-sub">Bot configuration · execution history · P&amp;L</p>
</div>
</div>
{/* Bot config: settings + HL key */}
<BotConfigPanel />
{/* Stats */}
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4,1fr)', marginBottom: 20 }}>
<div className="kpi"><div className="label">Total trades</div><div className="value">{filtered.length}</div></div>
<div className="kpi"><div className="label">Win rate</div><div className="value">{priced.length ? ((wins/priced.length)*100).toFixed(1)+'%' : ''}</div><div className="foot"><span>{wins}W · {losses}L</span></div></div>
<div className="kpi accent"><div className="label">Net P&amp;L</div><div className="value">{fmtMoney(totalPnl,{sign:true,decimals:0})}</div></div>
<div className="kpi"><div className="label">Avg hold</div><div className="value">{avgHold ? fmtHold(avgHold) : ''}</div></div>
</div>
{/* Filters */}
<div className="row gap-s" style={{ marginBottom: 14 }}>
<div className="nav-tabs">
{(['all','BTC','ETH'] as const).map(a => (
<button key={a} className={`nav-tab ${assetFilter===a?'active':''}`} onClick={() => setAssetFilter(a)}>
{a==='all'?'All assets':a}
</button>
))}
</div>
<div className="nav-tabs">
{(['all','long','short'] as const).map(s => (
<button key={s} className={`nav-tab ${sideFilter===s?'active':''}`} onClick={() => setSideFilter(s)}>
{s.charAt(0).toUpperCase()+s.slice(1)}
</button>
))}
</div>
</div>
{loading && <div style={{ textAlign:'center', padding:60, color:'var(--ink-3)' }}>Loading…</div>}
{!loading && (
<div className="card flush" style={{ overflow:'hidden' }}>
<table className="table">
<thead>
<tr>
<th>Asset</th><th>Side</th><th>Entry</th><th>Exit</th>
<th>Hold</th><th>Trigger post</th><th>P&amp;L</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={7} style={{ textAlign:'center', padding:40, color:'var(--ink-3)' }}>No trades found</td></tr>
)}
{filtered.map(t => {
const tp = posts.find(p => p.id === t.trigger_post_id)
// Guard: entry_price may be 0 on a corrupt row; exit_price may
// be null for externally-closed trades. Both → NaN otherwise.
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
return (
<tr key={t.id}>
<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'?' LONG':' 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="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>
)}
</div>
)
return <TradesPageClient />
}
+182
View File
@@ -0,0 +1,182 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useLocale } from 'next-intl'
import type { TrumpPost } from '@/types'
import { getPosts } from '@/lib/api'
import { swrFetch } from '@/lib/cache'
import PostRow from '@/components/dashboard/PostCards'
import SystemControl from '@/components/signals/SystemControl'
/**
* System 1 — Trump (event-driven scalp). Its own dedicated page.
* Shows ONLY source === 'truth'. No scanner panel (that's System 2).
*/
const SENTIMENTS = ['all', 'bullish', 'bearish', 'neutral'] as const
type SentimentFilter = (typeof SENTIMENTS)[number]
type SignalFilter = 'all' | 'actionable' | 'buy' | 'short'
interface TrumpSignalPageProps {
initialPosts?: TrumpPost[] | null
}
export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPageProps) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts ?? [])
const [loading, setLoading] = useState(initialPosts === null)
const [loadErr, setLoadErr] = useState('')
const [sentFilter, setSentFilter] = useState<SentimentFilter>('all')
const [sigFilter, setSigFilter] = useState<SignalFilter>('all')
useEffect(() => {
swrFetch(
'posts-500',
3 * 60_000, // 3 min TTL
() => getPosts(500, 1),
fresh => setPosts(fresh), // background revalidation callback
)
.then(p => { setPosts(p); setLoadErr('') })
.catch(e => setLoadErr(e instanceof Error ? e.message : (isZh ? '帖子加载失败' : 'Failed to load posts')))
.finally(() => setLoading(false))
}, [isZh])
const trumpPosts = useMemo(
() => posts.filter(p => (p.source || '') === 'truth'),
[posts],
)
const filtered = useMemo(() => trumpPosts.filter(p => {
if (sentFilter !== 'all' && p.sentiment !== sentFilter) return false
if (sigFilter === 'actionable' && p.signal !== 'buy' && p.signal !== 'short') return false
if (sigFilter === 'buy' && p.signal !== 'buy') return false
if (sigFilter === 'short' && p.signal !== 'short') return false
return true
}), [trumpPosts, sentFilter, sigFilter])
const sigCounts = useMemo(() => ({
all: trumpPosts.length,
actionable: trumpPosts.filter(p => p.signal === 'buy' || p.signal === 'short').length,
buy: trumpPosts.filter(p => p.signal === 'buy').length,
short: trumpPosts.filter(p => p.signal === 'short').length,
}), [trumpPosts])
const signalLabels: Record<SignalFilter, string> = {
all: isZh ? '全部' : 'All',
actionable: isZh ? '可执行' : 'Actionable',
buy: isZh ? '做多' : 'Buy',
short: isZh ? '做空' : 'Short',
}
const sentimentLabels: Record<SentimentFilter, string> = {
all: isZh ? '全部' : 'All',
bullish: isZh ? '看多' : 'Bullish',
bearish: isZh ? '看空' : 'Bearish',
neutral: isZh ? '中性' : 'Neutral',
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '① Trump 信号' : '① Trump Signal'}</h1>
<p className="page-sub">
{`Event-driven scalp · ${sigCounts.actionable} actionable · ${trumpPosts.length} posts`}
</p>
</div>
<span className="chip"><span className="live-dot" />Live</span>
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5, marginBottom: 12 }}>
This engine watches Trump Truth Social in real time, classifies each post, and only opens trades on high-confidence market-moving events. It is built for tight risk, short holds, and a 12-hour cooldown between entries.
</div>
<SystemControl system="trump" />
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
gap: 12, margin: '16px 0 12px', flexWrap: 'wrap',
}}>
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{([
{ key: 'all' },
{ key: 'actionable' },
{ key: 'buy' },
{ key: 'short' },
] as const).map(f => (
<button
key={f.key}
className={`nav-tab ${sigFilter === f.key ? 'active' : ''}`}
onClick={() => setSigFilter(f.key)}
>
{f.key === 'actionable' && !isZh ? '🔥 ' : ''}
{f.key === 'actionable' && isZh ? '🔥 ' : ''}
{signalLabels[f.key]}
<span style={{ color: 'var(--ink-4)', marginLeft: 4 }}>{sigCounts[f.key]}</span>
</button>
))}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{SENTIMENTS.map(f => (
<button
key={f}
onClick={() => setSentFilter(f)}
style={{
padding: '4px 10px', borderRadius: 6, border: '1px solid var(--line)',
background: sentFilter === f ? 'var(--ink)' : 'transparent',
color: sentFilter === f ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: sentFilter === f ? 600 : 400,
}}
>
{sentimentLabels[f]}
</button>
))}
</div>
</div>
{loading && <TrumpSkeleton />}
{!loading && loadErr && (
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--down)' }}>
{`Couldnt load signals — ${loadErr}`}
<div style={{ marginTop: 10 }}>
<button className="btn ghost" style={{ fontSize: 12, padding: '6px 14px' }}
onClick={() => location.reload()}>{isZh ? '重试' : 'Retry'}</button>
</div>
</div>
)}
{!loading && !loadErr && filtered.length === 0 && (
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
No Trump signals match the current filter.
</div>
)}
{!loading && filtered.length > 0 && (
<div className="post-stream">
{filtered.map(p => <PostRow key={p.id} post={p} />)}
</div>
)}
</div>
)
}
function TrumpSkeleton() {
return (
<div className="post-stream" style={{ marginTop: 8 }}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton-card" style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="skeleton sk-line sk-w-q" />
<div className="skeleton sk-line" style={{ width: 60 }} />
</div>
<div className="skeleton sk-title sk-w-3q" />
<div className="skeleton sk-line sk-w-full" />
<div className="skeleton sk-line sk-w-half" />
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<div className="skeleton sk-line-sm" style={{ width: 56 }} />
<div className="skeleton sk-line-sm" style={{ width: 80 }} />
<div className="skeleton sk-line-sm" style={{ width: 64 }} />
</div>
</div>
))}
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { getPosts } from '@/lib/api'
import type { Metadata } from 'next'
import TrumpPageClient from './TrumpPageClient'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
export const revalidate = 30
interface TrumpPageProps {
params: Promise<{ locale: string }>
}
export async function generateMetadata({
params,
}: TrumpPageProps): Promise<Metadata> {
const { locale } = await params
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return {
title: isZh ? 'Trump Truth Social 实时信号' : 'Trump Truth Social Signals',
description: isZh
? '实时抓取并打分 Donald Trump 的 Truth Social 帖子,3 秒内判断是 LONG、SHORT 还是噪音,并可选择在 Hyperliquid 上以逐仓模式自动执行。'
: 'Real-time AI scoring of every Trump Truth Social post. Classified as LONG, SHORT, or NOISE within 3 seconds. Optional auto-trade on Hyperliquid with isolated margin.',
keywords: isZh
? [
'Trump 加密信号',
'Truth Social 交易信号',
'特朗普发帖行情',
'Trump 自动交易',
'Hyperliquid 机器人',
'Trump BTC 信号',
]
: [
'Trump crypto signal',
'Truth Social crypto',
'Trump Truth Social trading bot',
'Trump market signal',
'crypto auto-trader',
'Hyperliquid bot',
'Trump BTC signal',
],
openGraph: {
title: isZh ? 'Trump Truth Social 实时信号 | Trump Alpha' : 'Trump Truth Social Signals | Trump Alpha',
description: isZh
? '每条 Trump 帖子都在 3 秒内完成 AI 分类,输出方向、信心分和是否可执行,并支持 Hyperliquid 自动交易。'
: 'Every Trump post scored by AI in under 3 seconds. LONG, SHORT, or NOISE — with optional Hyperliquid auto-trade.',
},
alternates: {
canonical: `${siteUrl}/${locale}/trump`,
languages: {
en: `${siteUrl}/en/trump`,
'x-default': `${siteUrl}/en/trump`,
},
},
}
}
export default async function TrumpPage() {
const posts = await getPosts(500, 1).catch(() => null)
return <TrumpPageClient initialPosts={posts} />
}