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
+1
View File
@@ -5,6 +5,7 @@
"name": "trumpsignal", "name": "trumpsignal",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"cwd": "/Users/k/Public/Claude/trumpsignal",
"port": 3001 "port": 3001
} }
] ]
+5
View File
@@ -9,3 +9,8 @@ NEXT_PUBLIC_API_URL=https://api.yourdomain.com
# Dev: ws://localhost:8000 # Dev: ws://localhost:8000
# Production: wss://api.yourdomain.com # Production: wss://api.yourdomain.com
NEXT_PUBLIC_WS_URL=wss://api.yourdomain.com NEXT_PUBLIC_WS_URL=wss://api.yourdomain.com
# Public site URL used for sitemap.xml and robots.txt
# Dev: http://localhost:3001
# Production: https://yourdomain.com
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
+126 -20
View File
@@ -1,17 +1,21 @@
'use client' 'use client'
import { useState, useEffect } from 'react' 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 type { TrumpPost, BotPerformance, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard' import { useDashboardStore } from '@/store/dashboard'
import { usePriceSocket } from '@/lib/useRealtimeData' 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 ChartPanel from '@/components/dashboard/ChartPanel'
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards' 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 { interface Props {
initialPosts: TrumpPost[] initialPosts: TrumpPost[]
initialPerformance?: BotPerformance
} }
// ── Inline post detail panel shown in the right rail ────────────────────────── // ── 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"/> <path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg> </svg>
<p style={{ fontSize: 13, margin: 0, lineHeight: 1.5 }}> <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> </p>
</div> </div>
) )
} }
// ── Main dashboard ───────────────────────────────────────────────────────────── // ── 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() 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') } function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') }
const { address, isConnected } = useAccount() 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 [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
const [performance, setPerformance] = useState<BotPerformance | undefined>(undefined)
const [candles, setCandles] = useState<Candle[]>([]) const [candles, setCandles] = useState<Candle[]>([])
const [chartErr, setChartErr] = useState('')
const [chartReload, setChartReload] = useState(0)
const [selectedPostId, setSelectedPostId] = useState<number | null>(null) const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
// For 1D: show all posts from selected day // For 1D: show all posts from selected day
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null) const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
@@ -146,6 +158,7 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
setSubscribed(false) setSubscribed(false)
setHlApiKeySet(false) setHlApiKeySet(false)
setBotReadiness('unknown') setBotReadiness('unknown')
setPerformance(undefined)
return return
} }
// Clear account-scoped bot state immediately when the connected wallet // Clear account-scoped bot state immediately when the connected wallet
@@ -162,6 +175,25 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
.catch(() => {}) .catch(() => {})
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness]) }, [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({ usePriceSocket({
onPrice: (a, price) => setLivePrice(a, price), onPrice: (a, price) => setLivePrice(a, price),
onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 500)), onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 500)),
@@ -169,13 +201,31 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
useEffect(() => { useEffect(() => {
setCandles([]) setCandles([])
setChartErr('')
getPrices(asset, timeframe) getPrices(asset, timeframe)
.then(setCandles) .then(c => { setCandles(c); setChartErr('') })
.catch(() => {}) .catch(e => setChartErr(e instanceof Error ? e.message : (isZh ? '价格数据加载失败' : 'Failed to load price data')))
}, [asset, timeframe]) }, [asset, timeframe, chartReload, isZh])
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null 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] const lastCandle = candles[candles.length - 1]
// Live price beats the most recent candle close — the candle is only // 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 todayKey = new Date().toISOString().slice(0, 10)
const signalsToday = posts.filter(p => p.published_at?.slice(0, 10) === todayKey).length 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 actionablePosts = posts.filter(p => p.signal === 'buy' || p.signal === 'short').length
const winRate = initialPerformance?.win_rate ?? 0 const winRate = performance?.win_rate ?? 0
const netPnl = initialPerformance?.net_pnl_usd ?? 0 const netPnl = performance?.net_pnl_usd ?? 0
const hasPriceData = candles.length > 0 const hasPriceData = candles.length > 0
const hasPerformanceData = Boolean(initialPerformance) const hasPerformanceData = Boolean(performance)
return ( return (
<div className="page wide"> <div className="page wide">
<div className="page-head"> <div className="page-head">
<div> <div>
<h1 className="page-title">Signal monitor</h1> <h1 className="page-title">{isZh ? '信号总览' : 'Signal monitor'}</h1>
<p className="page-sub"> <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> </p>
</div> </div>
<div className="row gap-s"> <div className="row gap-s">
<span className="chip"><span className="live-dot" />Live feed</span> <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>
</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 */} {/* KPI Row */}
<div className="kpi-row"> <div className="kpi-row">
<div className="kpi"> <div className="kpi">
@@ -238,17 +329,17 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
<div className="kpi"> <div className="kpi">
<div className="label">Signals today</div> <div className="label">Signals today</div>
<div className="value">{signalsToday}</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>
<div className="kpi accent"> <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="value">{netPnl >= 0 ? '+$' : '-$'}{Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}</div>
<div className="foot"><span>{hasPerformanceData ? 'Bot performance' : 'Performance pending'}</span></div> <div className="foot"><span>{hasPerformanceData ? 'Bot performance' : 'Performance pending'}</span></div>
</div> </div>
<div className="kpi"> <div className="kpi">
<div className="label">Win rate</div> <div className="label">Win rate</div>
<div className="value">{initialPerformance ? (winRate * 100).toFixed(1) + '%' : '—'}</div> <div className="value">{performance ? (winRate * 100).toFixed(1) + '%' : '—'}</div>
<div className="foot"><span>{hasPerformanceData ? `${initialPerformance?.total_trades ?? 0} trades` : 'Waiting for trade history'}</span></div> <div className="foot"><span>{hasPerformanceData ? `${performance?.total_trades ?? 0} trades` : 'Connect wallet to load your trade history'}</span></div>
</div> </div>
</div> </div>
@@ -283,6 +374,15 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
</div> </div>
</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"> <div className="chart-wrap">
<ChartPanel <ChartPanel
posts={posts} posts={posts}
@@ -311,7 +411,10 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
<div> <div>
<div className="section-title"> <div className="section-title">
<h2>Recent signals</h2> <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>
<div className="post-stream"> <div className="post-stream">
{recentPosts.map(p => ( {recentPosts.map(p => (
@@ -429,6 +532,9 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
) : ( ) : (
<SelectHint /> <SelectHint />
)} )}
{/* Breakout signal monitor */}
<SignalMonitor />
</div> </div>
</div> </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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { config } from '@/lib/wagmi' import { config } from '@/lib/wagmi'
import { WsProvider } from '@/lib/wsContext'
import '@rainbow-me/rainbowkit/styles.css' import '@rainbow-me/rainbowkit/styles.css'
interface ProvidersProps { interface ProvidersProps {
@@ -15,7 +16,7 @@ export default function Providers({ children }: ProvidersProps) {
const [queryClient] = useState(() => new QueryClient()) const [queryClient] = useState(() => new QueryClient())
return ( return (
<WagmiProvider config={config}> <WagmiProvider config={config} reconnectOnMount={false}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RainbowKitProvider <RainbowKitProvider
theme={darkTheme({ theme={darkTheme({
@@ -25,7 +26,10 @@ export default function Providers({ children }: ProvidersProps) {
overlayBlur: 'small', overlayBlur: 'small',
})} })}
> >
{children} {/* Single shared WebSocket — all components subscribe via useWsSubscribe */}
<WsProvider>
{children}
</WsProvider>
</RainbowKitProvider> </RainbowKitProvider>
</QueryClientProvider> </QueryClientProvider>
</WagmiProvider> </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' const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
import { getPerformance, getTrades } from '@/lib/api'
import type { BotPerformance, BotTrade } from '@/types'
type Period = '7d' | '30d' | '90d' | 'All' export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale()
function fmtMoney(n: number) { const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
if (n == null || isNaN(n)) return '—' const title = isZh ? '交易分析与表现' : 'Trading Analytics & Performance'
const abs = Math.abs(n) const description = isZh
const s = abs.toLocaleString('en-US', { maximumFractionDigits: 0 }) ? '查看胜率、最大回撤、平均持仓时间、信号准确率和历史交易表现,评估 Trump Alpha 的真实执行质量。'
if (n < 0) return '-$' + s : 'Review win rate, drawdown, average hold time, signal accuracy, and historical trade performance to evaluate live execution quality.'
if (n > 0) return '+$' + s return {
return '$' + s title,
} description,
alternates: {
function fmtHold(s: number) { canonical: `${siteUrl}/${locale}/analytics`,
if (s < 60) return s + 's' languages: {
const m = Math.floor(s / 60) en: `${siteUrl}/en/analytics`,
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)
}
} }
return maxDrawdownPct
} }
export default function AnalyticsPage() { export default function AnalyticsPage() {
const [perf, setPerf] = useState<BotPerformance | null>(null) return <AnalyticsPageClient />
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>
)
} }
+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 ( return (
<div className="page" style={{ maxWidth: 560 }}> <div className="page" style={{ maxWidth: 560 }}>
<div className="page-head"> <div className="page-head">
<div> <div>
<h1 className="page-title">Contact Us</h1> <h1 className="page-title">{isZh ? '联系我们' : 'Contact Us'}</h1>
<p className="page-sub">Questions, feedback, or support requests.</p> <p className="page-sub">{isZh ? '问题、反馈或支持请求都可以从这里发来。' : 'Questions, feedback, or support requests.'}</p>
</div> </div>
</div> </div>
@@ -15,30 +20,30 @@ export default function ContactPage() {
encType="text/plain" encType="text/plain"
> >
<div className="field" style={{ marginBottom: 16 }}> <div className="field" style={{ marginBottom: 16 }}>
<label htmlFor="subject">Subject</label> <label htmlFor="subject">{isZh ? '主题' : 'Subject'}</label>
<input id="subject" name="subject" type="text" placeholder="e.g. Bot not executing trades" /> <input id="subject" name="subject" type="text" placeholder={isZh ? '例如:机器人没有执行交易' : 'e.g. Bot not executing trades'} />
</div> </div>
<div className="field" style={{ marginBottom: 24 }}> <div className="field" style={{ marginBottom: 24 }}>
<label htmlFor="body">Message</label> <label htmlFor="body">{isZh ? '内容' : 'Message'}</label>
<textarea <textarea
id="body" id="body"
name="body" name="body"
rows={6} rows={6}
placeholder="Describe your issue or question in detail…" placeholder={isZh ? '尽量详细描述你的问题或需求…' : 'Describe your issue or question in detail…'}
style={{ width: '100%', resize: 'vertical' }} style={{ width: '100%', resize: 'vertical' }}
/> />
</div> </div>
<button type="submit" className="btn amber lg"> <button type="submit" className="btn amber lg">
Open in mail client {isZh ? '用邮件客户端打开 →' : 'Open in mail client →'}
</button> </button>
</form> </form>
<div style={{ marginTop: 28, paddingTop: 20, borderTop: '1px solid var(--line)', fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.6 }}> <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> <div>
<a href="mailto:support@bitnews.day" style={{ color: 'var(--amber)' }}>support@bitnews.day</a> <a href="mailto:support@bitnews.day" style={{ color: 'var(--amber)' }}>support@bitnews.day</a>
</div> </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> </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-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); --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; --sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
--mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; --mono: var(--font-geist-mono), ui-monospace, 'SF Mono', Menlo, monospace;
} }
html[data-theme="dark"] { html[data-theme="dark"] {
@@ -953,6 +953,12 @@ html[data-theme="dark"] .ai-reasoning-card {
} }
.src-ico.x { background: #111; color: #fff; } .src-ico.x { background: #111; color: #fff; }
.src-ico.truth { background: oklch(94% 0.05 25); color: oklch(45% 0.2 25); } .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 { .post-body .meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1347,3 +1353,40 @@ html, body { max-width: 100%; overflow-x: hidden; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 999px; border: 2px solid var(--bg); } ::-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); } ::-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 { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server' import { getMessages, getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { locales } from '@/i18n' import { locales } from '@/i18n'
import Providers from './Providers' import Providers from './Providers'
import Navbar from '@/components/nav/Navbar' import Navbar from '@/components/nav/Navbar'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
interface LayoutProps { interface LayoutProps {
children: React.ReactNode 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])) { if (!locales.includes(locale as (typeof locales)[number])) {
notFound() notFound()
} }
const messages = await getMessages() const messages = await getMessages()
const t = await getTranslations({ locale, namespace: 'footer' })
const href = (path: string) => `/${locale}${path}` const href = (path: string) => `/${locale}${path}`
return ( return (
@@ -35,10 +58,13 @@ export default async function LocaleLayout({ children, params: { locale } }: Lay
color: 'var(--ink-3)', color: 'var(--ink-3)',
marginTop: 48, marginTop: 48,
}}> }}>
<span>© {new Date().getFullYear()} TrumpSignal</span> <span>© {new Date().getFullYear()} Trump Alpha</span>
<Link href={href('/privacy')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>Privacy</Link> <Link href={href('/methodology')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>{t('methodology')}</Link>
<Link href={href('/terms')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>Terms</Link> <Link href={href('/glossary')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>{t('glossary')}</Link>
<Link href={href('/contact')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>Contact</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> </footer>
</Providers> </Providers>
</NextIntlClientProvider> </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' import DashboardClient from './DashboardClient'
export const revalidate = 30 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() { export default async function OverviewPage() {
const [posts, performance] = await Promise.allSettled([ const posts = await getPosts(500, 1).catch(() => [])
getPosts(500, 1),
getPerformance(),
])
return ( return (
<DashboardClient <DashboardClient
initialPosts={posts.status === 'fulfilled' ? posts.value : []} initialPosts={posts}
initialPerformance={performance.status === 'fulfilled' ? performance.value : undefined}
/> />
) )
} }
+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' * Legacy route. The old "Signals" hub was replaced by two top-level nav
import { getPosts } from '@/lib/api' * tabs (Trump / BTC). Anything still pointing here (old links, sitemap,
import PostRow from '@/components/dashboard/PostCards' * bookmarks) lands on the Trump system page.
*/
export default function PostsPage() { export default function LegacySignalsRedirect({
const [posts, setPosts] = useState<TrumpPost[]>([]) params,
const [loading, setLoading] = useState(true) }: {
const [filter, setFilter] = useState('all') params: { locale: string }
}) {
useEffect(() => { redirect(`/${params.locale}/trump`)
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>
)
} }
+74 -42
View File
@@ -1,55 +1,87 @@
import Link from 'next/link' 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 ( return (
<div className="page" style={{ maxWidth: 720 }}> <div className="page" style={{ maxWidth: 720 }}>
<div className="page-head"> <div className="page-head">
<div> <div>
<h1 className="page-title">Privacy Policy</h1> <h1 className="page-title">{isZh ? '隐私政策' : 'Privacy Policy'}</h1>
<p className="page-sub">Last updated: April 2026</p> <p className="page-sub">{isZh ? '最后更新:2026 年 4 月' : 'Last updated: April 2026'}</p>
</div> </div>
</div> </div>
<div className="card" style={{ padding: 32, lineHeight: 1.7, fontSize: 14, color: 'var(--ink-2)' }}> <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> {sections.map((section, index) => (
<p style={{ marginBottom: 20 }}> <div key={section.title} style={{ marginBottom: index === sections.length - 1 ? 0 : 20 }}>
We collect your Ethereum wallet address when you connect your wallet. This address is used solely to <h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>{section.title}</h2>
authenticate your session and manage your subscription. We do not collect your name, email address, or <p style={{ marginBottom: 0 }}>
any other personally identifiable information unless you voluntarily provide it via the contact form. {section.title.endsWith('Contact') || section.title.endsWith('联系方式')
</p> ? <>
{section.body}{' '}
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>2. Hyperliquid API Keys</h2> <Link href={`/${locale}/contact`} style={{ color: 'var(--amber)' }}>{isZh ? '联系页面' : 'contact page'}</Link>.
<p style={{ marginBottom: 20 }}> </>
If you choose to connect a Hyperliquid API key, it is encrypted at rest using industry-standard symmetric : section.body}
encryption (Fernet/AES-128-CBC). The plaintext key is never logged, cached, or transmitted except to </p>
Hyperliquid&apos;s official API endpoints to execute trades on your behalf. </div>
</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>
</div> </div>
</div> </div>
) )
+58 -25
View File
@@ -1,48 +1,81 @@
'use client' 'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useLocale } from 'next-intl'
import { useAccount } from 'wagmi' 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() { 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 { address, isConnected } = useAccount()
const [mounted, setMounted] = useState(false)
const pathname = usePathname() const pathname = usePathname()
useEffect(() => { setMounted(true) }, [])
const locale = pathname.split('/')[1] || 'en' const locale = pathname.split('/')[1] || 'en'
const href = (path: string) => `/${locale}${path}` const href = (path: string) => `/${locale}${path}`
return ( return (
<div style={{ maxWidth: 560 }}> <div>
{/* Account card */} {/* Account card — quick "who am I logged in as" */}
<div className="card" style={{ padding: 24, marginBottom: 16 }}> <div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div className="section-title" style={{ marginBottom: 16 }}><h2>Account</h2></div> <div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
<div className="field"> textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 8 }}>
<label>Connected wallet</label> {isZh ? '账户' : 'Account'}
<input disabled value={isConnected && address ? `${address.slice(0, 10)}${address.slice(-8)}` : 'Not connected'} />
</div> </div>
</div> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
{/* Link to Trades page for bot config */} width: 32, height: 32, borderRadius: 8,
<div className="card" style={{ padding: 24, marginBottom: 16 }}> background: mounted && isConnected ? 'var(--up-soft)' : 'var(--bg-sunk)',
<div className="row between" style={{ alignItems: 'center' }}> display: 'flex', alignItems: 'center', justifyContent: 'center',
color: mounted && isConnected ? 'var(--up)' : 'var(--ink-4)', fontSize: 14,
}}>
{mounted && isConnected ? '✓' : '○'}
</div>
<div> <div>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Bot &amp; exchange settings</div> <div style={{ fontSize: 13, fontWeight: 500 }}>
<div style={{ fontSize: 13, color: 'var(--ink-3)' }}> {mounted && isConnected && address
Position size, leverage, TP/SL, Hyperliquid API key, and subscription are managed on the Trades page. ? <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>
</div> </div>
<Link href={href('/trades')} className="btn ghost" style={{ whiteSpace: 'nowrap', marginLeft: 16 }}>
Go to Trades
</Link>
</div> </div>
</div> </div>
{/* Legal links */} {/* The full bot config UI (subscribe, HL key, risk settings, schedule) */}
<div className="card" style={{ padding: 24 }}> <BotConfigPanel />
<div className="section-title" style={{ marginBottom: 16 }}><h2>Legal</h2></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> {/* Telegram push alerts — only renders something useful when wallet
<Link href={href('/privacy')} style={{ fontSize: 14, color: 'var(--ink-2)', textDecoration: 'none' }}>Privacy Policy </Link> connected + server configured + (eventually) subscribed. */}
<Link href={href('/terms')} style={{ fontSize: 14, color: 'var(--ink-2)', textDecoration: 'none' }}>Terms of Service </Link> <TelegramCard />
<Link href={href('/contact')} style={{ fontSize: 14, color: 'var(--ink-2)', textDecoration: 'none' }}>Contact Us </Link>
{/* 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> </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' 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 ( return (
<div className="page"> <div className="page">
<div className="page-head"> <div className="page-head">
<div> <div>
<h1 className="page-title">Settings</h1> <h1 className="page-title">{isZh ? '设置' : 'Settings'}</h1>
<p className="page-sub">Configure your bot, alerts, and account.</p> <p className="page-sub">{isZh ? '订阅、交易所密钥、风险限制和运行时间安排,都会在这里配置。' : 'Subscription, exchange key, risk limits, and schedule — everything that decides how the bot trades.'}</p>
</div> </div>
</div> </div>
<SettingsClient /> <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 ( return (
<div className="page" style={{ maxWidth: 720 }}> <div className="page" style={{ maxWidth: 720 }}>
<div className="page-head"> <div className="page-head">
<div> <div>
<h1 className="page-title">Terms of Service</h1> <h1 className="page-title">{isZh ? '服务条款' : 'Terms of Service'}</h1>
<p className="page-sub">Last updated: April 2026</p> <p className="page-sub">{isZh ? '最后更新:2026 年 4 月' : 'Last updated: April 2026'}</p>
</div> </div>
</div> </div>
<div className="card" style={{ padding: 32, lineHeight: 1.7, fontSize: 14, color: 'var(--ink-2)' }}> <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> {sections.map((section, index) => (
<p style={{ marginBottom: 20 }}> <div key={section.title} style={{ marginBottom: index === sections.length - 1 ? 0 : 20 }}>
By connecting your wallet or using TrumpSignal, you agree to these Terms of Service. If you do not <h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>{section.title}</h2>
agree, do not use the service. <p style={{ marginBottom: 0 }}>
</p> {section.title.includes('Not Financial Advice') || section.title.includes('非投资建议')
? <><strong style={{ color: 'var(--ink)' }}>{section.body}</strong></>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>2. Not Financial Advice</h2> : section.body}
<p style={{ marginBottom: 20 }}> </p>
<strong style={{ color: 'var(--ink)' }}>TrumpSignal is a research and automation tool, not a financial advisor.</strong>{' '} </div>
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>
</div> </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' const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
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'
// ─── helpers ────────────────────────────────────────────────────────────────── export async function generateMetadata(): Promise<Metadata> {
function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) { const locale = await getLocale()
const { decimals = 2, sign = false } = opts const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
if (n == null || isNaN(n)) return '—' const title = isZh ? '交易执行与持仓' : 'Trades & Open Positions'
const abs = Math.abs(n) const description = isZh
const s = abs.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) ? '查看当前持仓、历史成交、按信号来源拆分的盈亏,以及机器人是否已经具备真实执行条件。'
if (n < 0) return '-$' + s : 'Review open positions, historical executions, source-level P&L, and whether the bot is ready for live trading.'
if (sign && n > 0) return '+$' + s return {
return '$' + s title,
} description,
function fmtPct(n: number) { return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' } alternates: {
function fmtHold(s: number) { canonical: `${siteUrl}/${locale}/trades`,
if (s < 60) return s + 's' languages: {
const m = Math.floor(s / 60) en: `${siteUrl}/en/trades`,
if (m < 60) return m + 'm' },
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm' },
}
} }
// 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() { export default function TradesPage() {
const [trades, setTrades] = useState<BotTrade[]>([]) return <TradesPageClient />
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>
)
} }
+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} />
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Apple touch icon (180×180) — also used as the PWA icon source.
*
* iOS Safari grabs <link rel="apple-touch-icon"> when "Add to Home Screen"
* is invoked. Next.js auto-wires this file to that link.
*/
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const size = { width: 180, height: 180 }
export const contentType = 'image/png'
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
background: '#0a0907',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 132,
fontWeight: 700,
color: '#f5a524',
fontFamily: 'system-ui, sans-serif',
lineHeight: 1,
borderRadius: 24,
}}
>
α
</div>
),
{ ...size },
)
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Dynamic favicon. Renders the brand mark (α) on the dark brand background.
*
* Next.js auto-discovers this file and wires it into the page <head> as
* <link rel="icon"> for every route. No static asset needed in /public.
*
* For larger PWA install icons (192/512), see app/apple-icon.tsx and the
* manifest.ts entries that reference them.
*/
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const size = { width: 64, height: 64 }
export const contentType = 'image/png'
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
background: '#0a0907',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 44,
fontWeight: 700,
color: '#f5a524',
fontFamily: 'system-ui, sans-serif',
lineHeight: 1,
}}
>
α
</div>
),
{ ...size },
)
}
+408 -14
View File
@@ -233,25 +233,20 @@
transition: all 0.18s ease; transition: all 0.18s ease;
cursor: pointer; border: none; cursor: pointer; border: none;
} }
/* Primary CTA — kept static on purpose. Previously the button had a magnetic
pull, a shimmer sweep, a hover lift, and a glow expansion stacked together.
Multiple users found it busy/distracting, so the button now just sits with
a fixed amber gradient + soft glow. Don't add hover transforms here. */
.lp-btn-primary { .lp-btn-primary {
color: #0a0907; color: #0a0907;
background: linear-gradient(135deg, #ffd88a, var(--lp-amber) 55%, #ff7a3d); background: linear-gradient(135deg, #ffd88a, var(--lp-amber) 55%, #ff7a3d);
box-shadow: 0 10px 40px rgba(245,165,36,0.35), inset 0 1px 0 rgba(255,255,255,0.4); box-shadow: 0 10px 40px rgba(245,165,36,0.35), inset 0 1px 0 rgba(255,255,255,0.4);
position: relative; position: relative;
overflow: hidden;
}
.lp-btn-primary::before {
content: '';
position: absolute; inset: 0;
background: linear-gradient(120deg, transparent 30%, rgba(255,255,255,0.4) 50%, transparent 70%);
transform: translateX(-150%);
transition: transform 0.7s cubic-bezier(.2,.8,.2,1);
} }
.lp-btn-primary:hover { .lp-btn-primary:hover {
transform: translateY(-2px); /* Intentionally no transform / box-shadow change — see comment above. */
box-shadow: 0 16px 50px rgba(245,165,36,0.55); filter: brightness(1.05);
} }
.lp-btn-primary:hover::before { transform: translateX(150%); }
.lp-btn-secondary { .lp-btn-secondary {
color: var(--lp-ink); color: var(--lp-ink);
background: var(--lp-surface); background: var(--lp-surface);
@@ -307,7 +302,7 @@
/* ---------- Section ---------- */ /* ---------- Section ---------- */
.lp-section { .lp-section {
padding: 120px 0; padding: 80px 0;
position: relative; position: relative;
} }
.lp-eyebrow { .lp-eyebrow {
@@ -327,10 +322,10 @@
} }
.lp-lead { .lp-lead {
max-width: 600px; max-width: 600px;
font-size: 18px; font-size: 17px;
line-height: 1.55; line-height: 1.55;
color: var(--lp-ink-2); color: var(--lp-ink-2);
margin: 0 0 56px; margin: 0 0 40px;
} }
/* ---------- Scroll reveal ---------- */ /* ---------- Scroll reveal ---------- */
@@ -1557,3 +1552,402 @@
/* Prevent horizontal scroll anywhere on the landing root */ /* Prevent horizontal scroll anywhere on the landing root */
.lp-root { overflow-x: hidden; } .lp-root { overflow-x: hidden; }
.lp-root img, .lp-root video { max-width: 100%; height: auto; } .lp-root img, .lp-root video { max-width: 100%; height: auto; }
/* ============================================================
✨ Premium fx layer (cursor spotlight, noise, scanlines, 3D
tilt cards, scramble text, magnetic buttons, count-up stats)
Added later — appended so existing rules win on conflict.
============================================================ */
/* ── Defaults so layouts don't jump before JS sets the vars ── */
.lp-root {
--mx: 50vw;
--my: 50vh;
}
/* ── 1. Cursor-tracking radial spotlight ──────────────────── */
.lp-spotlight {
position: fixed; inset: 0;
z-index: 0;
pointer-events: none;
background: radial-gradient(
600px circle at var(--mx) var(--my),
rgba(245,165,36,0.10),
rgba(245,165,36,0.04) 30%,
transparent 60%
);
mix-blend-mode: screen;
transition: background 80ms linear;
}
/* ── 2. Film grain (SVG noise data URI, tiny, no asset needed) ── */
.lp-noise {
position: fixed; inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.06;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .8 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
background-size: 160px 160px;
animation: lp-noise-shift 5s steps(8) infinite;
}
@keyframes lp-noise-shift {
0% { transform: translate(0,0); }
20% { transform: translate(-5%, 3%); }
40% { transform: translate(3%, -5%); }
60% { transform: translate(-3%, -3%); }
80% { transform: translate(5%, 4%); }
100% { transform: translate(0,0); }
}
/* ── 3. CRT scanlines (very subtle) ───────────────────────── */
.lp-scanlines {
position: fixed; inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.04;
background: repeating-linear-gradient(
to bottom,
transparent 0,
transparent 2px,
rgba(255,255,255,0.4) 3px,
transparent 4px
);
mix-blend-mode: overlay;
}
/* ── 4. Scramble / decode text ─────────────────────────────── */
.lp-decode-line {
display: inline-block;
/* Use tabular nums + a slightly narrower spacing while scrambling so
the heading doesn't reflow as random glyphs swap. */
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
/* Soft glow on decoded text */
text-shadow: 0 0 22px rgba(245,165,36,0.12);
}
.lp-decode-line.grad {
text-shadow: none; /* gradient text can't take text-shadow cleanly */
}
/* ── 5. Magnetic button wrapper — DISABLED ─────────────────────
The .lp-magnet class is no longer applied to any element (see app/page.tsx
hero CTA). Rules kept commented for reference in case the effect is
reinstated later, but they should NOT match anything in production. */
/*
.lp-magnet { will-change: transform; }
.lp-btn-inner {
display: inline-flex; align-items: center; gap: 10px;
transition: transform 0.18s ease;
}
.lp-magnet:hover .lp-btn-inner { transform: translateY(-1px); }
.lp-magnet.lp-btn-primary:hover { transform: none !important; }
.lp-magnet.lp-btn-primary { transition: box-shadow 0.18s ease; }
.lp-magnet.lp-btn-primary:hover {
box-shadow: 0 18px 60px rgba(245,165,36,0.6);
}
*/
/* ── 6. Hero stats (counter strip) ────────────────────────── */
.lp-herostats {
margin: 56px auto 0;
align-self: center; /* center inside .lp-hero flex column */
display: inline-flex; align-items: stretch; gap: 28px;
padding: 18px 28px;
border: 1px solid var(--lp-line);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01)),
rgba(10,9,7,0.55);
backdrop-filter: blur(10px);
box-shadow:
0 30px 80px rgba(0,0,0,0.4),
inset 0 1px 0 rgba(255,255,255,0.04);
font-family: 'Geist Mono', ui-monospace, monospace;
}
.lp-herostat { text-align: center; }
.lp-herostat-val {
font-size: clamp(22px, 3vw, 30px);
font-weight: 700;
color: var(--lp-ink);
line-height: 1;
letter-spacing: -0.02em;
display: inline-flex; align-items: baseline;
}
.lp-herostat-val .suf {
font-size: 0.6em;
color: var(--lp-amber);
margin-left: 1px;
}
.lp-herostat-val .dollar {
font-size: 0.7em;
color: var(--lp-amber);
margin-right: 1px;
}
.lp-herostat-lbl {
font-size: 10px;
color: var(--lp-ink-4);
margin-top: 6px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.lp-herostat-sep {
width: 1px;
background: linear-gradient(180deg, transparent, var(--lp-line), transparent);
align-self: stretch;
}
@media (max-width: 560px) {
.lp-herostats { gap: 16px; padding: 14px 18px; }
}
/* ── 7. PILLAR CARDS — 3D tilt + spotlight + animated edge ── */
.lp-pillar {
--tilt-x: 0deg;
--tilt-y: 0deg;
--glow-x: 50%;
--glow-y: 50%;
position: relative;
display: flex; flex-direction: column;
text-decoration: none;
color: inherit;
padding: 0;
background: transparent;
border: none;
border-radius: 20px;
transform-style: preserve-3d;
transform: perspective(900px) rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
transition: transform 0.18s cubic-bezier(.2,.8,.2,1);
cursor: pointer;
isolation: isolate;
}
.lp-pillar:hover { transition: transform 0.05s linear; }
/* solid body sits inside, separated from edge layer */
.lp-pillar-body {
position: relative;
z-index: 2;
display: flex; flex-direction: column; gap: 14px;
padding: 28px 26px;
background: linear-gradient(180deg, rgba(20,18,14,0.92), rgba(10,9,7,0.92));
border-radius: 18px;
border: 1px solid var(--lp-line);
height: 100%;
transform: translateZ(0); /* establish 3D layer */
}
.lp-pillar:hover .lp-pillar-body {
border-color: rgba(245,165,36,0.35);
}
/* spotlight inside the card — radial gradient driven by --glow-x/y */
.lp-pillar-glow {
position: absolute;
inset: 0;
z-index: 3;
border-radius: 18px;
pointer-events: none;
opacity: 0;
transition: opacity 0.25s ease;
background: radial-gradient(
400px circle at var(--glow-x) var(--glow-y),
rgba(245,165,36,0.18),
transparent 50%
);
mix-blend-mode: screen;
}
.lp-pillar:hover .lp-pillar-glow { opacity: 1; }
/* animated conic-gradient edge — rotates on hover */
.lp-pillar-edge {
position: absolute;
inset: -1px;
z-index: 1;
border-radius: 19px;
padding: 1px;
background: conic-gradient(
from var(--edge-angle, 0deg),
rgba(245,165,36,0.0),
rgba(245,165,36,0.45),
rgba(167,139,250,0.45),
rgba(239,68,68,0.4),
rgba(245,165,36,0.0)
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.3s ease;
animation: lp-edge-spin 4s linear infinite paused;
}
.lp-pillar:hover .lp-pillar-edge {
opacity: 1;
animation-play-state: running;
}
@property --edge-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
@keyframes lp-edge-spin {
to { --edge-angle: 360deg; }
}
/* typography inside pillar */
.lp-pillar-tag {
align-self: flex-start;
font-size: 10px; font-weight: 700;
letter-spacing: 0.08em; text-transform: uppercase;
padding: 4px 8px; border-radius: 999px;
background: rgba(245,165,36,0.12);
color: var(--lp-amber);
font-family: 'Geist Mono', ui-monospace, monospace;
}
.lp-pillar-title {
font-size: 18px; font-weight: 700;
margin: 0; line-height: 1.25;
color: var(--lp-ink);
}
.lp-pillar-desc {
font-size: 14px; line-height: 1.6;
color: var(--lp-ink-3);
margin: 0; flex: 1;
}
.lp-pillar-foot {
display: flex; align-items: baseline; gap: 8px; margin-top: 4px;
padding-top: 12px; border-top: 1px solid var(--lp-line);
}
.lp-pillar-metric {
font-size: 22px; font-weight: 700;
color: var(--lp-amber);
font-family: 'Geist Mono', ui-monospace, monospace;
line-height: 1;
}
.lp-pillar-metric-lbl {
font-size: 11px; color: var(--lp-ink-4);
font-family: 'Geist Mono', ui-monospace, monospace;
}
.lp-pillar-arrow {
margin-left: auto; font-size: 16px; color: var(--lp-amber);
transition: transform 0.2s ease;
}
.lp-pillar:hover .lp-pillar-arrow { transform: translateX(4px); }
/* reduce motion respect */
@media (prefers-reduced-motion: reduce) {
.lp-noise,
.lp-pillar-edge { animation: none !important; }
.lp-pillar { transform: none !important; transition: none; }
.lp-spotlight { display: none; }
}
/* ── FAQ ──────────────────────────────────────────────── */
.lp-faq {
max-width: 720px;
margin-top: 32px;
display: flex;
flex-direction: column;
gap: 2px;
}
.lp-faq-item {
border: 1px solid var(--lp-line);
border-radius: 10px;
overflow: hidden;
background: var(--lp-surface);
transition: border-color 0.2s;
}
.lp-faq-item:hover { border-color: var(--lp-line-2); }
.lp-faq-q {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 18px 20px;
background: none;
border: none;
color: var(--lp-ink-2);
font-size: 14px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: color 0.2s;
line-height: 1.4;
}
.lp-faq-q:hover,
.lp-faq-q.open { color: var(--lp-ink); }
.lp-faq-chevron {
flex-shrink: 0;
font-size: 20px;
color: var(--lp-amber);
font-family: 'Geist Mono', monospace;
line-height: 1;
transition: transform 0.2s;
}
.lp-faq-a {
padding: 0 20px 18px;
border-top: 1px solid var(--lp-line);
}
.lp-faq-a p {
margin: 14px 0 0;
font-size: 13px;
line-height: 1.65;
color: var(--lp-ink-3);
}
/* ── Comparison table ────────────────────────────────── */
.lp-compare-wrap {
margin-top: 32px;
overflow-x: auto;
border-radius: 12px;
border: 1px solid var(--lp-line-2);
}
.lp-compare {
width: 100%;
border-collapse: collapse;
font-size: 13px;
min-width: 540px;
}
.lp-compare thead tr {
background: rgba(255,255,255,0.04);
border-bottom: 1px solid var(--lp-line-2);
}
.lp-compare th {
padding: 14px 18px;
text-align: left;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--lp-ink-3);
font-family: 'Geist Mono', monospace;
white-space: nowrap;
}
.lp-compare th.lp-compare-us {
color: var(--lp-amber);
background: rgba(245,165,36,0.06);
}
.lp-compare tbody tr {
border-bottom: 1px solid var(--lp-line);
transition: background 0.15s;
}
.lp-compare tbody tr:last-child { border-bottom: none; }
.lp-compare tbody tr:hover { background: rgba(255,255,255,0.025); }
.lp-compare-feature {
padding: 13px 18px;
color: var(--lp-ink-2);
font-weight: 500;
}
.lp-compare-val {
padding: 13px 18px;
font-family: 'Geist Mono', monospace;
font-size: 12px;
white-space: nowrap;
}
td.lp-compare-us {
color: var(--lp-amber);
background: rgba(245,165,36,0.04);
font-weight: 600;
}
.lp-compare-muted { color: var(--lp-ink-4); }
+209 -17
View File
@@ -1,15 +1,28 @@
import type { Metadata, Viewport } from 'next' import type { Metadata, Viewport } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import { getLocale } from 'next-intl/server'
import Script from 'next/script' import Script from 'next/script'
import './[locale]/globals.css' import './[locale]/globals.css'
const siteTitle = 'Trump Alpha' const siteTitle = 'Trump Alpha'
const siteDescription = 'Track Trump social posts, AI-scored BTC and ETH signals, and auto-trader performance in one live dashboard.' const siteTagline = 'Four crypto alpha feeds, one dashboard'
const siteDescription = 'Real-time Trump Truth Social signals, BTC macro-bottom confluence, KOL Substack/podcast signals, and talks-vs-trades divergence — all scored by AI in one live dashboard.'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
const geistSans = Geist({
subsets: ['latin'],
variable: '--font-geist-sans',
})
const geistMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-geist-mono',
})
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: siteUrl ? new URL(siteUrl) : undefined, metadataBase: siteUrl ? new URL(siteUrl) : new URL('https://trumpsignal.com'),
title: { title: {
default: `${siteTitle} | Trade Trump's Truth Social posts, automatically`, default: `${siteTitle} | ${siteTagline}`,
template: `%s | ${siteTitle}`, template: `%s | ${siteTitle}`,
}, },
description: siteDescription, description: siteDescription,
@@ -18,22 +31,74 @@ export const metadata: Metadata = {
'Trump Alpha', 'Trump Alpha',
'TrumpSignal', 'TrumpSignal',
'crypto signals', 'crypto signals',
'BTC trading', 'crypto alpha dashboard',
'ETH trading', 'BTC bottom reversal',
'Bitcoin macro bottom',
'AHR999',
'Pi Cycle Bottom',
'KOL crypto signals',
'Arthur Hayes signals',
'Delphi Digital',
'Bankless',
'KOL on-chain tracking',
'talks vs trades divergence',
'smart-money divergence',
'AI trading dashboard', 'AI trading dashboard',
'Trump social sentiment', 'Trump Truth Social signal',
'Hyperliquid bot', 'Hyperliquid trading bot',
'crypto auto-trader',
'crypto intelligence platform',
'funding rate reversal',
'Bitcoin cycle bottom',
], ],
authors: [{ name: 'Trump Alpha' }],
creator: 'Trump Alpha',
publisher: 'Trump Alpha',
category: 'Finance',
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
alternates: {
canonical: siteUrl || 'https://trumpsignal.com',
languages: {
'en': `${siteUrl || 'https://trumpsignal.com'}/en`,
'x-default': siteUrl || 'https://trumpsignal.com',
},
},
openGraph: { openGraph: {
title: `${siteTitle} | Trade Trump's Truth Social posts, automatically`, title: `${siteTitle} | ${siteTagline}`,
description: siteDescription, description: siteDescription,
siteName: siteTitle, siteName: siteTitle,
type: 'website', type: 'website',
locale: 'en_US',
url: siteUrl || 'https://trumpsignal.com',
images: [
{
url: '/opengraph-image',
width: 1200,
height: 630,
alt: `${siteTitle}${siteTagline}`,
type: 'image/png',
},
],
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
title: `${siteTitle} | Trade Trump's Truth Social posts, automatically`, title: `${siteTitle} | ${siteTagline}`,
description: siteDescription, description: siteDescription,
images: ['/opengraph-image'],
},
verification: {
// Add your Google Search Console verification token here
// google: 'YOUR_GOOGLE_VERIFICATION_TOKEN',
}, },
} }
@@ -44,18 +109,145 @@ export const viewport: Viewport = {
viewportFit: 'cover', viewportFit: 'cover',
} }
export default function RootLayout({ children }: { children: React.ReactNode }) { const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'SoftwareApplication',
name: 'Trump Alpha',
alternateName: 'TrumpSignal',
url: siteUrl || 'https://trumpsignal.com',
applicationCategory: 'FinanceApplication',
operatingSystem: 'Web',
description: siteDescription,
datePublished: '2025-01-01',
dateModified: new Date().toISOString().slice(0, 10),
inLanguage: ['en'],
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
description: 'Free to read all signals. Optional Hyperliquid integration for auto-trading.',
},
featureList: [
'Real-time Trump Truth Social sentiment scoring',
'BTC macro-bottom confluence detection (AHR999 + 200-week MA + Pi Cycle Bottom)',
'KOL Substack and podcast signal extraction',
'Talks-vs-trades divergence detection',
'Hyperliquid auto-trader integration',
],
},
{
'@type': 'Organization',
name: 'Trump Alpha',
url: siteUrl || 'https://trumpsignal.com',
description: 'AI-powered crypto intelligence platform tracking Trump posts, on-chain signals, and KOL sentiment.',
},
{
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'What is Trump Alpha?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Trump Alpha is an AI-powered crypto intelligence dashboard that monitors four signal sources: Trump Truth Social posts, BTC macro-bottom confluence signals, KOL Substack and podcast essays, and talks-vs-trades divergence between public commentary and on-chain behavior.',
},
},
{
'@type': 'Question',
name: 'How does the Trump signal engine work?',
acceptedAnswer: {
'@type': 'Answer',
text: 'The engine scrapes Trump\'s Truth Social feed continuously. Each post is sent to an AI model that classifies it as LONG (bullish crypto), SHORT (bearish), or NOISE (off-topic). If the confidence score exceeds your threshold, the engine optionally places an isolated-margin trade on Hyperliquid. The entire pipeline runs in under 3 seconds from post to position.',
},
},
{
'@type': 'Question',
name: 'What is the BTC bottom reversal signal?',
acceptedAnswer: {
'@type': 'Answer',
text: 'The BTC bottom reversal module is a long-only macro-bottom scanner. It fires when at least two of three classic bottom signals agree: AHR999 deep-value zone, price near the 200-week moving average, and Pi Cycle Bottom. These signals are intentionally rare and aimed at cycle-level reversals rather than short-term swings.',
},
},
{
'@type': 'Question',
name: 'What is talks-vs-trades divergence?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Talks-vs-trades divergence compares what a crypto KOL says publicly (e.g., "I\'m bullish on ETH") against what their on-chain wallet actually does within a 7-day window. When a KOL is publicly bullish but their wallet is selling, Trump Alpha flags it as a divergence signal — the on-chain action is treated as ground truth, making the divergence one of the highest-conviction alpha signals on the platform.',
},
},
{
'@type': 'Question',
name: 'Which KOLs does Trump Alpha track?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Trump Alpha tracks 19 KOL feeds including Arthur Hayes (BitMEX), Delphi Digital, Dragonfly Capital, Pomp, and major crypto podcasts (Empire, Bankless, Unchained, 0xResearch, Lightspeed). Posts and episodes are processed daily; AI extracts ticker calls, direction (bullish/bearish/buy/sell), and conviction scores.',
},
},
{
'@type': 'Question',
name: 'Is Trump Alpha free?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Yes. All four signal dashboards (Trump, BTC bottom, KOL, talks-vs-trades) are free to read. The optional Hyperliquid auto-trader requires your own Hyperliquid account and API key. Trump Alpha never takes custody of your funds — your API key can open and close positions but cannot withdraw.',
},
},
{
'@type': 'Question',
name: 'How do I set up the Hyperliquid auto-trader?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Create a Hyperliquid account at app.hyperliquid.xyz, generate an API key with trade-only permissions (no withdrawal), and paste it into the Trump Alpha settings page. Configure your leverage (default 3×), position size, TP/SL levels, daily budget cap, and active trading hours. The bot only trades when all your conditions are met.',
},
},
{
'@type': 'Question',
name: 'What safety features does the auto-trader have?',
acceptedAnswer: {
'@type': 'Answer',
text: 'The Trump Alpha auto-trader has six safety layers: (1) minimum confidence threshold before any trade fires, (2) isolated margin so a bad trade cannot drain other positions, (3) automatic take-profit and stop-loss on every open position, (4) daily budget cap — it stops trading once the cap is hit, (5) active hours window so it only trades when you choose, (6) per-trade position size cap.',
},
},
{
'@type': 'Question',
name: 'What is the BTC macro-bottom scanner looking for?',
acceptedAnswer: {
'@type': 'Answer',
text: 'It is looking for rare macro-bottom environments rather than trying to pick intraday reversals. The live signal requires 2-of-3 agreement across AHR999, the 200-week moving average, and Pi Cycle Bottom, then manages the position with staged de-risking, profit-floor ratchets, and an 18-month backstop.',
},
},
{
'@type': 'Question',
name: 'Does Trump Alpha store my trading keys?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Your Hyperliquid API key is stored encrypted in your browser\'s local settings and sent only to the Trump Alpha backend to execute trades. The key is scoped to trade-only permissions — it cannot withdraw funds. We never ask for your MetaMask seed phrase or any wallet private key.',
},
},
],
},
],
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
let locale = 'en'
try {
locale = await getLocale()
} catch {
locale = 'en'
}
return ( return (
<html lang="en"> <html lang={locale === 'zh' ? 'zh-CN' : 'en'}>
<head> <head>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <script
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> type="application/ld+json"
<link dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/> />
</head> </head>
<body> <body className={`${geistSans.variable} ${geistMono.variable}`}>
{/* Umami analytics — loaded after interactive so it never blocks render */} {/* Umami analytics — loaded after interactive so it never blocks render */}
<Script <Script
src="https://stats.bitnews.day/script.js" src="https://stats.bitnews.day/script.js"
+42
View File
@@ -0,0 +1,42 @@
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Trump Alpha',
short_name: 'Trump Alpha',
description: 'Four crypto alpha feeds, one dashboard. Trump signals, BTC on-chain, KOL analysis, talks-vs-trades.',
start_url: '/en',
display: 'standalone',
background_color: '#0a0907',
theme_color: '#f5a524',
orientation: 'portrait-primary',
categories: ['finance', 'utilities'],
// Icons sourced from Next's dynamic generators (app/icon.tsx,
// app/apple-icon.tsx) — no static PNG asset required. `sizes: 'any'` lets
// the install prompt accept whatever resolution the OS asks for since
// ImageResponse renders at any size we declare in those files.
icons: [
{
src: '/icon',
sizes: 'any',
type: 'image/png',
purpose: 'any',
},
{
src: '/apple-icon',
sizes: '180x180',
type: 'image/png',
purpose: 'maskable',
},
],
screenshots: [
{
src: '/opengraph-image',
sizes: '1200x630',
type: 'image/png',
form_factor: 'wide',
label: 'Trump Alpha dashboard',
},
],
}
}
+171
View File
@@ -0,0 +1,171 @@
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const alt = 'Trump Alpha — Four crypto alpha feeds, one dashboard'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default function OGImage() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: '64px 72px',
background: '#0a0907',
position: 'relative',
overflow: 'hidden',
fontFamily: 'system-ui, sans-serif',
}}
>
{/* Background grid */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'linear-gradient(rgba(245,165,36,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(245,165,36,0.04) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
{/* Ambient glow */}
<div
style={{
position: 'absolute',
top: -200,
left: -200,
width: 800,
height: 800,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(245,165,36,0.12) 0%, transparent 70%)',
}}
/>
{/* Top-right corner badge */}
<div
style={{
position: 'absolute',
top: 48,
right: 72,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 14px',
borderRadius: 999,
background: 'rgba(245,165,36,0.12)',
border: '1px solid rgba(245,165,36,0.3)',
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: '#22c55e',
}}
/>
<span style={{ fontSize: 14, color: '#f5a524', fontWeight: 700, letterSpacing: '0.06em' }}>
LIVE
</span>
</div>
{/* Four signal pills */}
<div
style={{
display: 'flex',
gap: 12,
marginBottom: 40,
flexWrap: 'wrap',
}}
>
{[
{ label: 'Trump · Truth Social', color: '#f5a524' },
{ label: 'BTC Bottom Reversal', color: '#a78bfa' },
{ label: 'KOL Signal', color: '#22c55e' },
{ label: 'Talks vs Trades', color: '#ef4444' },
].map((p) => (
<div
key={p.label}
style={{
display: 'flex',
padding: '7px 16px',
borderRadius: 6,
background: 'rgba(255,255,255,0.05)',
border: `1px solid ${p.color}40`,
fontSize: 13,
fontWeight: 600,
color: p.color,
letterSpacing: '0.02em',
}}
>
{p.label}
</div>
))}
</div>
{/* Main title */}
<div
style={{
fontSize: 72,
fontWeight: 800,
lineHeight: 1.05,
color: '#f5f2ea',
marginBottom: 24,
letterSpacing: '-0.03em',
}}
>
Trump Alpha
</div>
{/* Sub */}
<div
style={{
fontSize: 24,
color: '#8a8578',
fontWeight: 400,
lineHeight: 1.5,
maxWidth: 700,
marginBottom: 0,
}}
>
Four crypto alpha feeds. AI-scored in real time.
<br />
One dashboard zero noise.
</div>
{/* Bottom right stat strip */}
<div
style={{
position: 'absolute',
bottom: 52,
right: 72,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 6,
}}
>
{[
{ v: '<3s', l: 'post → position' },
{ v: '15', l: 'KOL feeds' },
{ v: '$0', l: 'platform fee' },
].map((s) => (
<div key={s.l} style={{ display: 'flex', gap: 10, alignItems: 'baseline' }}>
<span style={{ fontSize: 22, fontWeight: 700, color: '#f5a524', fontFamily: 'monospace' }}>
{s.v}
</span>
<span style={{ fontSize: 13, color: '#5c584e' }}>{s.l}</span>
</div>
))}
</div>
</div>
),
{ ...size },
)
}
+469 -57
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import Link from 'next/link' import Link from 'next/link'
import './landing.css' import './landing.css'
@@ -43,14 +43,200 @@ function useClock() {
return t return t
} }
/* Global cursor → CSS vars (--mx, --my) at lp-root for spotlight + parallax. */
function useCursorVars(ref: React.RefObject<HTMLElement>) {
useEffect(() => {
const el = ref.current
if (!el) return
let raf = 0
let x = 0, y = 0
function onMove(e: PointerEvent) {
x = e.clientX
y = e.clientY
if (raf) return
raf = requestAnimationFrame(() => {
if (!el) return
el.style.setProperty('--mx', `${x}px`)
el.style.setProperty('--my', `${y}px`)
raf = 0
})
}
window.addEventListener('pointermove', onMove, { passive: true })
return () => {
window.removeEventListener('pointermove', onMove)
if (raf) cancelAnimationFrame(raf)
}
}, [ref])
}
/* Text scramble — decodes from random glyphs to final letters. Inspired by
classic "Matrix decode" effects. Runs once on mount. */
const SCRAMBLE_CHARS = '!<>-_\\/[]{}—=+*^?#01ABCDEFX$@&%'
function useScramble(target: string, durationMs = 1200) {
const [out, setOut] = useState(target.replace(/[^\s]/g, '·'))
useEffect(() => {
let raf = 0
const start = performance.now()
const seed = Array.from(target, () => Math.random() * 0.4)
function tick(now: number) {
const t = Math.min(1, (now - start) / durationMs)
let s = ''
for (let i = 0; i < target.length; i++) {
const c = target[i]
if (c === '\n' || c === ' ') { s += c; continue }
const localStart = seed[i]
if (t >= localStart + 0.6) s += c
else {
s += SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)]
}
}
setOut(s)
if (t < 1) raf = requestAnimationFrame(tick)
else setOut(target)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [target, durationMs])
return out
}
/* Magnetic — element drifts subtly toward cursor when nearby. Returns
ref + style. Strength = 0..1 (how strong the pull). */
function useMagnetic<T extends HTMLElement>(strength = 0.35) {
const ref = useRef<T>(null)
useEffect(() => {
const el = ref.current
if (!el) return
let raf = 0
function onMove(e: PointerEvent) {
const r = el!.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
const dx = e.clientX - cx
const dy = e.clientY - cy
const dist = Math.hypot(dx, dy)
const maxDist = Math.max(r.width, r.height)
if (dist > maxDist) {
if (raf) return
raf = requestAnimationFrame(() => {
el!.style.transform = ''
raf = 0
})
return
}
const k = strength * (1 - dist / maxDist)
if (raf) return
raf = requestAnimationFrame(() => {
el!.style.transform = `translate(${dx * k}px, ${dy * k}px)`
raf = 0
})
}
function onLeave() { el!.style.transform = '' }
window.addEventListener('pointermove', onMove, { passive: true })
el.addEventListener('pointerleave', onLeave)
return () => {
window.removeEventListener('pointermove', onMove)
el?.removeEventListener('pointerleave', onLeave)
if (raf) cancelAnimationFrame(raf)
}
}, [strength])
return ref
}
/* 3D tilt — sets --tilt-x / --tilt-y / --glow-x / --glow-y on the element.
CSS picks them up for the rotation + spotlight. */
function useTilt<T extends HTMLElement>(maxDeg = 8) {
const ref = useRef<T>(null)
useEffect(() => {
const el = ref.current
if (!el) return
let raf = 0
function onMove(e: PointerEvent) {
const r = el!.getBoundingClientRect()
const px = (e.clientX - r.left) / r.width // 0..1
const py = (e.clientY - r.top) / r.height
const rx = (py - 0.5) * -2 * maxDeg
const ry = (px - 0.5) * 2 * maxDeg
if (raf) return
raf = requestAnimationFrame(() => {
el!.style.setProperty('--tilt-x', `${rx}deg`)
el!.style.setProperty('--tilt-y', `${ry}deg`)
el!.style.setProperty('--glow-x', `${px * 100}%`)
el!.style.setProperty('--glow-y', `${py * 100}%`)
raf = 0
})
}
function reset() {
el!.style.setProperty('--tilt-x', '0deg')
el!.style.setProperty('--tilt-y', '0deg')
}
el.addEventListener('pointermove', onMove, { passive: true })
el.addEventListener('pointerleave', reset)
return () => {
el?.removeEventListener('pointermove', onMove)
el?.removeEventListener('pointerleave', reset)
if (raf) cancelAnimationFrame(raf)
}
}, [maxDeg])
return ref
}
/* Count-up — animates from 0 to target when the element enters the viewport. */
function useCountUp(target: number, durationMs = 1400) {
const ref = useRef<HTMLSpanElement>(null)
const [val, setVal] = useState(0)
useEffect(() => {
const el = ref.current
if (!el) return
let raf = 0
let start = 0
function tick(now: number) {
if (!start) start = now
const t = Math.min(1, (now - start) / durationMs)
// ease-out cubic
const eased = 1 - Math.pow(1 - t, 3)
setVal(Math.round(target * eased))
if (t < 1) raf = requestAnimationFrame(tick)
}
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
raf = requestAnimationFrame(tick)
io.disconnect()
}
}
},
{ threshold: 0.4 },
)
io.observe(el)
return () => {
io.disconnect()
if (raf) cancelAnimationFrame(raf)
}
}, [target, durationMs])
return [ref, val] as const
}
export default function LandingPage() { export default function LandingPage() {
const rootRef = useReveal() const rootRef = useReveal()
const clock = useClock() const clock = useClock()
useCursorVars(rootRef as React.RefObject<HTMLElement>)
// Decoded hero text — assembles from random glyphs on mount.
const heroLine1 = useScramble('Where crypto alpha actually lives —', 1100)
const heroLine2 = useScramble('all in one tab.', 1500)
// primaryRef / useMagnetic intentionally removed — the hero CTA is now a
// static button. The hook is still defined above in case the effect is
// reinstated on another element later.
return ( return (
<div className="lp-root" ref={rootRef}> <div className="lp-root" ref={rootRef}>
<div className="lp-mesh" aria-hidden /> <div className="lp-mesh" aria-hidden />
<div className="lp-grid" aria-hidden /> <div className="lp-grid" aria-hidden />
<div className="lp-spotlight" aria-hidden />
<div className="lp-noise" aria-hidden />
<div className="lp-scanlines" aria-hidden />
<nav className="lp-nav"> <nav className="lp-nav">
<div className="lp-nav-inner"> <div className="lp-nav-inner">
@@ -70,21 +256,19 @@ export default function LandingPage() {
<section className="lp-hero"> <section className="lp-hero">
<div className="lp-live-badge"> <div className="lp-live-badge">
<span className="lp-live-dot" /> <span className="lp-live-dot" />
Reading @realDonaldTrump on Truth Social · right now Six signal engines running · right now
</div> </div>
<h1 className="lp-h1"> <h1 className="lp-h1 lp-decode">
Trump posts at 3am.<br /> <span className="lp-decode-line">{heroLine1}</span>
The bot trades at&nbsp; <br />
<span className="strike"> <span className="grad lp-decode-line">{heroLine2}</span>
<span className="grad">3:00:02</span>
</span>
.
</h1> </h1>
<p className="lp-sub"> <p className="lp-sub">
Every Truth Social post is scraped, scored by AI, and turned into a Trump posts. On-chain bottoms. KOL essays. Wallets that move before
Hyperliquid position in under three seconds. You sleep. It doesn&rsquo;t. they tweet. We scrape every edge, score it with AI, and surface only
what&rsquo;s tradeable. You sleep. It doesn&rsquo;t.
</p> </p>
<div className="lp-ctas"> <div className="lp-ctas">
@@ -92,11 +276,15 @@ export default function LandingPage() {
Launch Dashboard Launch Dashboard
<span className="lp-arrow"></span> <span className="lp-arrow"></span>
</Link> </Link>
<a href="#engine" className="lp-btn lp-btn-secondary"> <a href="#pillars" className="lp-btn lp-btn-secondary">
Watch the engine See all six signals
</a> </a>
</div> </div>
{/* Live counter strip — animates in when hero is visible */}
<HeroStats />
{/* Ticker strip */} {/* Ticker strip */}
<div className="lp-ticker" aria-hidden> <div className="lp-ticker" aria-hidden>
<div className="lp-ticker-track"> <div className="lp-ticker-track">
@@ -114,17 +302,79 @@ export default function LandingPage() {
</div> </div>
</section> </section>
{/* ---------- THE ENGINE (terminal block) ---------- */} {/* ---------- FOUR PILLARS ---------- */}
<section id="pillars" className="lp-section" style={{ paddingTop: 60 }}>
<div className="lp-reveal" style={{ maxWidth: 680 }}>
<div className="lp-eyebrow">What runs inside</div>
<h2 className="lp-h2">
Six independent signal engines.<br />
<span className="grad">One dashboard, zero noise.</span>
</h2>
</div>
<div className="lp-features lp-reveal" style={{
marginTop: 28,
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
}}>
<PillarCard
tag="Real-time"
title="Trump · Truth Social"
desc="Every post scraped within 15s. AI scores sentiment, asset, confidence. Buy / short / skip decided in under 3s."
metric="<3s"
metricLabel="post → position"
href="/en/trump"
/>
<PillarCard
tag="Daily · macro"
title="BTC Bottom Reversal"
desc="2-of-3 confluence across AHR999, the 200-week moving average, and Pi Cycle Bottom. Rare, high-conviction long-only entries."
metric="24×"
metricLabel="signals per cycle"
href="/en/btc"
/>
<PillarCard
tag="Daily · narrative"
title="KOL Signal"
desc="19 crypto KOL feeds (Hayes, Delphi, Bankless…). AI extracts which tokens they called and how convicted they were."
metric="15"
metricLabel="live feeds"
href="/en/kol"
/>
<PillarCard
tag="Daily · cross-signal"
title="Talks vs Trades"
desc="KOL says bullish publicly, wallet is selling on-chain? You see the divergence first. Highest-conviction signal on the platform."
metric="⚠️"
metricLabel="words ≠ actions"
href="/en/kol"
/>
<PillarCard
tag="Hourly · derivatives"
title="Funding Rate Reversal"
desc="Funding spikes → crowded trade unwinds. At multi-cycle extremes, mean-reversion is the highest-probability play."
metric="±0.1%"
metricLabel="threshold trigger"
href="/en/btc"
/>
<PillarCard
tag="On demand · execution"
title="Hyperliquid Auto-Trader"
desc="Your leverage. Your TP/SL. Connects via trade-only key — can open and close, never withdraw. Six safety layers."
metric="6"
metricLabel="safety layers"
href="/en/settings"
/>
</div>
</section>
{/* ---------- THE ENGINE (terminal block) — Pillar 1 deep-dive ---------- */}
<section id="engine" className="lp-section" style={{ paddingTop: 60 }}> <section id="engine" className="lp-section" style={{ paddingTop: 60 }}>
<div className="lp-reveal" style={{ maxWidth: 640 }}> <div className="lp-reveal" style={{ maxWidth: 640 }}>
<div className="lp-eyebrow">The engine, running</div> <div className="lp-eyebrow">Pillar 1 · live engine</div>
<h2 className="lp-h2"> <h2 className="lp-h2">
No mock-ups. No dashboards in a marketing deck. Real feed. Not a mock-up.
</h2> </h2>
<p className="lp-lead">
This is what the signal engine actually does, every time Trump posts.
Scrape. Score. Decide. Execute.
</p>
</div> </div>
{/* Terminal */} {/* Terminal */}
@@ -186,29 +436,15 @@ export default function LandingPage() {
<Stat value="6" label="Safety layers" sub="TP · SL · hold · budget · window · isolation" /> <Stat value="6" label="Safety layers" sub="TP · SL · hold · budget · window · isolation" />
</div> </div>
<p
className="lp-reveal"
style={{
marginTop: 18, fontSize: 12, color: 'var(--lp-ink-4)',
textAlign: 'center', fontFamily: 'Geist Mono, monospace',
}}
>
Posts shown are examples in Trump&rsquo;s style your actual feed is live from the dashboard.
</p>
</section> </section>
{/* ---------- PRESS / HEADLINES ---------- */} {/* ---------- PRESS / HEADLINES ---------- */}
<section className="lp-section" style={{ paddingTop: 80 }}> <section className="lp-section" style={{ paddingTop: 60 }}>
<div className="lp-reveal" style={{ maxWidth: 680 }}> <div className="lp-reveal" style={{ maxWidth: 680 }}>
<div className="lp-eyebrow">Headlines you can Google</div> <div className="lp-eyebrow">Headlines you can Google</div>
<h2 className="lp-h2"> <h2 className="lp-h2">
The market already trades every Trump post. The market already trades<br />every Trump post.
</h2> </h2>
<p className="lp-lead">
Big wallets have been scoring eight-figure moves on his Truth Social
activity for two years. This bot won&rsquo;t give you their edge
it just gives you their speed.
</p>
</div> </div>
<div className="lp-press"> <div className="lp-press">
@@ -329,52 +565,139 @@ export default function LandingPage() {
{/* ---------- WHY IT WORKS ---------- */} {/* ---------- WHY IT WORKS ---------- */}
<section className="lp-section"> <section className="lp-section">
<div className="lp-reveal" style={{ maxWidth: 640 }}> <div className="lp-reveal" style={{ maxWidth: 660 }}>
<div className="lp-eyebrow">Why bother</div> <div className="lp-eyebrow">Why bother</div>
<h2 className="lp-h2"> <h2 className="lp-h2">
Trump moves markets. You can&rsquo;t outrun him manually. Trump posts at 3am.<br />
<span className="grad">The bot doesn&rsquo;t sleep.</span>
</h2> </h2>
<p className="lp-lead">
He posts at any hour, in any mood. By the time you see it, the move
is half over. The bot doesn&rsquo;t blink. That&rsquo;s the whole pitch.
</p>
</div> </div>
<div className="lp-why"> <div className="lp-why">
<WhyCard <WhyCard
n="01" n="01"
t="Your rules, not ours" t="Your rules"
d="Your leverage, your position size, your TP/SL, your daily cap, your active hours. The bot only does what your config says." d="Your leverage, size, TP/SL, daily cap, hours. The bot only does what your config says."
/> />
<WhyCard <WhyCard
n="02" n="02"
t="Isolated margin, always" t="Isolated margin"
d="Every open uses Hyperliquid isolated margin. A bad trade can&rsquo;t drain your other positions or your whole account." d="Every trade uses isolated margin. One bad signal can't drain your other positions."
/> />
<WhyCard <WhyCard
n="03" n="03"
t="Nothing held overnight" t="Read-only by default"
d="Every position force-closes after 60 minutes. No waking up to a liquidation from a post you missed." d="Five pillars are pure intelligence — read only. The auto-trader is opt-in."
/> />
<WhyCard <WhyCard
n="04" n="04"
t="Your keys stay yours" t="Non-custodial"
d="Non-custodial. The Hyperliquid API key can open and close — it cannot withdraw. We never see your MetaMask." d="API key can trade, never withdraw. We never see your MetaMask or seed phrase."
/>
</div>
</section>
{/* ---------- COMPARISON TABLE ---------- */}
<section id="compare" className="lp-section" style={{ paddingTop: 60 }}>
<div className="lp-reveal" style={{ maxWidth: 680 }}>
<div className="lp-eyebrow">How it compares</div>
<h2 className="lp-h2">
Free. Non-custodial.<br />
<span className="grad">No subscription.</span>
</h2>
</div>
<div className="lp-compare-wrap lp-reveal">
<table className="lp-compare" role="table" aria-label="Comparison of Trump Alpha vs alternatives">
<thead>
<tr>
<th scope="col">Feature</th>
<th scope="col" className="lp-compare-us">Trump Alpha</th>
<th scope="col">Kaito / Santiment</th>
<th scope="col">Manual research</th>
</tr>
</thead>
<tbody>
<CompRow f="Trump post → trade latency" us="<3 seconds" them="manual / N/A" manual="minuteshours" />
<CompRow f="BTC macro-bottom signal" us="✓ 2-of-3 confluence" them="paid tier" manual="✗" />
<CompRow f="KOL feed ingestion" us="19 feeds daily" them="limited / paid" manual="slow" />
<CompRow f="Talks-vs-trades divergence" us="✓ built-in" them="✗" manual="✗" />
<CompRow f="Auto-trader integration" us="✓ Hyperliquid" them="✗" manual="✗" />
<CompRow f="Price" us="Free" them="$49$299/mo" manual="time cost" />
<CompRow f="Custody of funds" us="Non-custodial" them="N/A" manual="self" />
<CompRow f="Isolated margin protection" us="✓ always" them="N/A" manual="your risk" />
</tbody>
</table>
</div>
</section>
{/* ---------- WHAT IS TRUMP ALPHA (canonical definition for GEO) ---------- */}
<section id="about" className="lp-section" style={{ paddingTop: 80 }}>
<div className="lp-reveal" style={{ maxWidth: 720 }}>
<div className="lp-eyebrow">What is Trump Alpha</div>
<h2 className="lp-h2">
One platform. Six independent<br />
<span className="grad">crypto intelligence engines.</span>
</h2>
<p className="lp-lead" style={{ marginBottom: 16 }}>
<strong>Trump Alpha</strong> is an AI-powered crypto intelligence dashboard that
aggregates six uncorrelated signal sources into one live feed Trump Truth Social
posts, BTC macro-bottom confluence (AHR999 + 200-week MA + Pi Cycle Bottom),
19 KOL narrative feeds, talks-vs-trades divergence, and a
Hyperliquid auto-trader. All signal reading is free. No custody. No subscription.
</p>
<p className="lp-lead" style={{ marginBottom: 0, fontSize: 15 }}>
<Link href="/en/methodology" style={{ color: 'var(--lp-amber)', textDecoration: 'none' }}>Signal methodology</Link>
{' · '}
<Link href="/en/glossary" style={{ color: 'var(--lp-amber)', textDecoration: 'none' }}>Glossary</Link>
{' · '}
<Link href="/en/case-studies" style={{ color: 'var(--lp-amber)', textDecoration: 'none' }}>Case studies</Link>
</p>
</div>
</section>
{/* ---------- FAQ (GEO + SERP feature snippets) ---------- */}
<section id="faq" className="lp-section" style={{ paddingTop: 60, paddingBottom: 80 }}>
<div className="lp-reveal" style={{ maxWidth: 680 }}>
<div className="lp-eyebrow">FAQ</div>
<h2 className="lp-h2">
Common questions<br />
<span className="grad">answered straight.</span>
</h2>
</div>
<div className="lp-faq lp-reveal">
<FaqItem
q="How does the Trump signal engine work?"
a="Every post is scraped within 15s of publish. An AI model classifies it LONG / SHORT / NOISE, scores confidence, and optionally opens an isolated-margin trade on Hyperliquid — the full pipeline runs in under 3 seconds."
/>
<FaqItem
q="What is talks-vs-trades divergence?"
a="Fires when a KOL says one thing publicly but their on-chain wallet does the opposite within 7 days. On-chain action is treated as ground truth. It's the platform's highest-conviction signal."
/>
<FaqItem
q="Is Trump Alpha free?"
a="All dashboards are free to read. The auto-trader uses your own Hyperliquid account — we take no cut and hold no funds. Nothing to subscribe to."
/>
<FaqItem
q="Is my money safe?"
a="The Hyperliquid API key can open and close trades — it cannot withdraw. Every position uses isolated margin so one bad trade can't touch the rest of your account."
/>
<FaqItem
q="How often does it update?"
a="Trump posts: within 15s of publish. BTC macro-bottom scans run daily, funding reversal runs hourly, and KOL feeds refresh on their scheduled daily jobs. The dashboard reflects the latest run automatically."
/> />
</div> </div>
</section> </section>
{/* ---------- FINAL CTA ---------- */} {/* ---------- FINAL CTA ---------- */}
<section className="lp-cta-final lp-reveal"> <section className="lp-cta-final lp-reveal">
<h2 className="lp-h2" style={{ margin: '0 auto 24px', maxWidth: 780 }}> <h2 className="lp-h2" style={{ margin: '0 auto 24px', maxWidth: 820 }}>
Stop refreshing Truth Social.<br /> Stop juggling tabs.<br />
<span className="grad" style={{ fontWeight: 700 }}> <span className="grad" style={{ fontWeight: 700 }}>
Go do literally anything else. One dashboard. Six edges.
</span> </span>
</h2> </h2>
<p className="lp-lead" style={{ margin: '0 auto 40px', textAlign: 'center' }}> <p className="lp-lead" style={{ margin: '0 auto 36px', textAlign: 'center', fontSize: 16 }}>
2 minutes to set up. Free. Bring your own Hyperliquid account. If you Free to read. 2 minutes to set up. Nothing to cancel.
don&rsquo;t like it, disconnect nothing to cancel.
</p> </p>
<div className="lp-ctas"> <div className="lp-ctas">
<Link href="/en" className="lp-btn lp-btn-primary"> <Link href="/en" className="lp-btn lp-btn-primary">
@@ -394,9 +717,13 @@ export default function LandingPage() {
</p> </p>
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<span>© {new Date().getFullYear()} TrumpSignal</span> <span>© {new Date().getFullYear()} TrumpSignal</span>
<Link href="/en/methodology">Methodology</Link>
<Link href="/en/glossary">Glossary</Link>
<Link href="/en/case-studies">Case Studies</Link>
<Link href="/en/privacy">Privacy</Link> <Link href="/en/privacy">Privacy</Link>
<Link href="/en/terms">Terms</Link> <Link href="/en/terms">Terms</Link>
<Link href="/en/contact">Contact</Link> <Link href="/en/contact">Contact</Link>
<Link href="/en">Dashboard</Link>
</div> </div>
</footer> </footer>
</div> </div>
@@ -498,8 +825,93 @@ function WhyCard({ n, t, d }: { n: string; t: string; d: string }) {
return ( return (
<div className="lp-why-card lp-reveal"> <div className="lp-why-card lp-reveal">
<div className="num">{n}</div> <div className="num">{n}</div>
<h4>{t}</h4> <h3 style={{ fontSize: 'inherit', fontWeight: 600, margin: '8px 0 4px' }}>{t}</h3>
<p>{d}</p> <p>{d}</p>
</div> </div>
) )
} }
function CompRow({ f, us, them, manual }: { f: string; us: string; them: string; manual: string }) {
return (
<tr>
<td className="lp-compare-feature">{f}</td>
<td className="lp-compare-us lp-compare-val">{us}</td>
<td className="lp-compare-val lp-compare-muted">{them}</td>
<td className="lp-compare-val lp-compare-muted">{manual}</td>
</tr>
)
}
function FaqItem({ q, a }: { q: string; a: string }) {
const [open, setOpen] = useState(false)
return (
<div className="lp-faq-item" itemScope itemType="https://schema.org/Question">
<button
className={`lp-faq-q${open ? ' open' : ''}`}
onClick={() => setOpen(!open)}
aria-expanded={open}
itemProp="name"
>
{q}
<span className="lp-faq-chevron" aria-hidden>{open ? '' : '+'}</span>
</button>
{open && (
<div className="lp-faq-a" itemScope itemType="https://schema.org/Answer">
<p itemProp="text">{a}</p>
</div>
)}
</div>
)
}
function PillarCard({
tag, title, desc, metric, metricLabel, href,
}: {
tag: string; title: string; desc: string; metric: string; metricLabel: string; href: string
}) {
const tiltRef = useTilt<HTMLAnchorElement>(7)
return (
<Link href={href} className="lp-feat lp-pillar lp-reveal" ref={tiltRef}>
{/* spotlight gradient that follows cursor inside the card */}
<span className="lp-pillar-glow" aria-hidden />
{/* animated gradient border (conic) */}
<span className="lp-pillar-edge" aria-hidden />
<div className="lp-pillar-body">
<span className="lp-pillar-tag">{tag}</span>
<h3 className="lp-pillar-title">{title}</h3>
<p className="lp-pillar-desc">{desc}</p>
<div className="lp-pillar-foot">
<span className="lp-pillar-metric">{metric}</span>
<span className="lp-pillar-metric-lbl">{metricLabel}</span>
<span className="lp-pillar-arrow"></span>
</div>
</div>
</Link>
)
}
/* Live stats strip under the hero. Numbers count up when visible. */
function HeroStats() {
const [r1, n1] = useCountUp(127)
const [r2, n2] = useCountUp(15)
const [r3, n3] = useCountUp(9750)
return (
<div className="lp-herostats lp-reveal" aria-label="Live platform stats">
<div className="lp-herostat">
<div className="lp-herostat-val"><span ref={r1}>{n1}</span><span className="suf">+</span></div>
<div className="lp-herostat-lbl">posts analyzed</div>
</div>
<div className="lp-herostat-sep" />
<div className="lp-herostat">
<div className="lp-herostat-val"><span ref={r2}>{n2}</span></div>
<div className="lp-herostat-lbl">live KOL feeds</div>
</div>
<div className="lp-herostat-sep" />
<div className="lp-herostat">
<div className="lp-herostat-val"><span className="dollar">$</span><span ref={r3}>{n3.toLocaleString()}</span><span className="suf">k</span></div>
<div className="lp-herostat-lbl">tracked wallet AUM</div>
</div>
</div>
)
}
+20 -12
View File
@@ -1,22 +1,30 @@
import type { MetadataRoute } from 'next' import type { MetadataRoute } from 'next'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
// Settings page is user-personalized (wallet address, HL keys, alert prefs).
// Disallow under every locale prefix in case any /zh URLs survive in the wild
// — i18n is shelved but the routes still resolve.
const SETTINGS_DENY = ['/api/', '/_next/', '/en/settings', '/zh/settings']
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
if (!siteUrl) { return {
return { rules: [
rules: { {
userAgent: '*', userAgent: '*',
allow: '/', allow: '/',
disallow: SETTINGS_DENY,
}, },
} // Explicitly allow AI crawlers (GEO — ensures ChatGPT/Perplexity/Claude index the site)
} { userAgent: 'GPTBot', allow: '/', disallow: SETTINGS_DENY },
{ userAgent: 'ChatGPT-User', allow: '/', disallow: SETTINGS_DENY },
return { { userAgent: 'PerplexityBot', allow: '/', disallow: SETTINGS_DENY },
rules: { { userAgent: 'ClaudeBot', allow: '/', disallow: SETTINGS_DENY },
userAgent: '*', { userAgent: 'anthropic-ai', allow: '/', disallow: ['/api/', '/_next/'] },
allow: '/', { userAgent: 'Googlebot', allow: '/' },
}, { userAgent: 'Bingbot', allow: '/' },
],
sitemap: `${siteUrl}/sitemap.xml`, sitemap: `${siteUrl}/sitemap.xml`,
host: siteUrl,
} }
} }
+54 -8
View File
@@ -1,19 +1,65 @@
import type { MetadataRoute } from 'next' import type { MetadataRoute } from 'next'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
// In production, warn loudly but don't crash — the sitemap will use the
// fallback URL. Set NEXT_PUBLIC_SITE_URL in your deployment env to get the
// correct absolute URLs in sitemap.xml.
if (!siteUrl && process.env.NODE_ENV === 'production') { if (!siteUrl && process.env.NODE_ENV === 'production') {
throw new Error('NEXT_PUBLIC_SITE_URL must be set in production builds (used by sitemap.xml)') console.warn(
'[sitemap] NEXT_PUBLIC_SITE_URL is not set — sitemap will use https://trumpsignal.com as fallback. ' +
'Set this env var in your deployment to avoid incorrect sitemap URLs.',
)
} }
const baseUrl = (siteUrl || 'http://localhost:3001').replace(/\/$/, '')
const routes = ['', '/posts', '/trades', '/analytics', '/settings', '/privacy', '/terms', '/contact'] const base = (siteUrl || 'https://trumpsignal.com').replace(/\/$/, '')
const routes: Array<{
path: string
priority: number
freq: MetadataRoute.Sitemap[number]['changeFrequency']
}> = [
{ path: '', priority: 1.0, freq: 'hourly' },
{ path: '/trump', priority: 0.9, freq: 'hourly' },
{ path: '/btc', priority: 0.9, freq: 'daily' },
{ path: '/kol', priority: 0.9, freq: 'daily' },
{ path: '/trades', priority: 0.8, freq: 'daily' },
{ path: '/analytics', priority: 0.7, freq: 'daily' },
{ path: '/methodology', priority: 0.8, freq: 'monthly' },
{ path: '/glossary', priority: 0.8, freq: 'monthly' },
{ path: '/case-studies', priority: 0.8, freq: 'monthly' },
{ path: '/settings', priority: 0.5, freq: 'monthly' },
{ path: '/privacy', priority: 0.3, freq: 'yearly' },
{ path: '/terms', priority: 0.3, freq: 'yearly' },
{ path: '/contact', priority: 0.4, freq: 'monthly' },
]
// i18n shelved — only emit English routes. Add 'zh' back when content is
// actually translated (otherwise Google flags duplicate content under hreflang).
const locales = ['en']
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date() const now = new Date()
const entries: MetadataRoute.Sitemap = []
return routes.map((route) => ({ // Root landing (not locale-scoped)
url: `${baseUrl}/en${route}`, entries.push({
url: base,
lastModified: now, lastModified: now,
changeFrequency: route === '' ? 'hourly' : 'daily', changeFrequency: 'hourly',
priority: route === '' ? 1 : 0.7, priority: 1.0,
})) })
for (const locale of locales) {
for (const r of routes) {
entries.push({
url: `${base}/${locale}${r.path}`,
lastModified: now,
changeFrequency: r.freq,
priority: r.priority,
})
}
}
return entries
} }
+12 -10
View File
@@ -6,6 +6,7 @@ import type { BotPerformance } from '@/types'
import { useDashboardStore } from '@/store/dashboard' import { useDashboardStore } from '@/store/dashboard'
import { getUserPublic, setHlApiKey, subscribe } from '@/lib/api' import { getUserPublic, setHlApiKey, subscribe } from '@/lib/api'
import { signRequest } from '@/lib/signedRequest' import { signRequest } from '@/lib/signedRequest'
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
// Action names must match backend/app/api/{user,subscribe}.py // Action names must match backend/app/api/{user,subscribe}.py
const ACTION_SET_API_KEY = 'set_hl_api_key' const ACTION_SET_API_KEY = 'set_hl_api_key'
@@ -30,12 +31,15 @@ export default function BotPanel({ performance }: Props) {
const { connect, connectors } = useConnect() const { connect, connectors } = useConnect()
const { signMessageAsync } = useSignMessage() const { signMessageAsync } = useSignMessage()
const [mounted, setMounted] = useState(false)
const [apiKey, setApiKey] = useState('') const [apiKey, setApiKey] = useState('')
const [saveState, setSaveState] = useState<SaveState>('idle') const [saveState, setSaveState] = useState<SaveState>('idle')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle') const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle')
const [subError, setSubError] = useState('') const [subError, setSubError] = useState('')
useEffect(() => { setMounted(true) }, [])
useEffect(() => { useEffect(() => {
if (!isConnected || !address) { if (!isConnected || !address) {
setSubscribed(false) setSubscribed(false)
@@ -79,8 +83,7 @@ export default function BotPanel({ performance }: Props) {
await refreshUserState(address) await refreshUserState(address)
setSubState('idle') setSubState('idle')
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error' setSubError(walletErrorLabel(err, 'Signature cancelled', 120))
setSubError(msg.includes('User rejected') || msg.includes('denied') ? 'Signature cancelled' : msg.slice(0, 120))
setSubState('error') setSubState('error')
} }
} }
@@ -109,11 +112,10 @@ export default function BotPanel({ performance }: Props) {
setApiKey('') setApiKey('')
setSaveState('success') setSaveState('success')
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error' if (isUserRejection(err)) {
if (msg.includes('User rejected') || msg.includes('denied')) {
setErrorMsg('Signature cancelled') setErrorMsg('Signature cancelled')
} else { } else {
setErrorMsg(msg.slice(0, 120)) setErrorMsg(walletErrorLabel(err, 'Signature cancelled', 120))
} }
setSaveState('error') setSaveState('error')
} }
@@ -166,13 +168,13 @@ export default function BotPanel({ performance }: Props) {
</div> </div>
<div className="bot-cta"> <div className="bot-cta">
{!isConnected && ( {(!mounted || !isConnected) && (
<button className="btn amber" style={{ width: '100%' }} <button className="btn amber" style={{ width: '100%' }}
onClick={handleConnectWallet}> onClick={handleConnectWallet}>
Connect wallet Connect wallet
</button> </button>
)} )}
{isConnected && !isSubscribed && ( {mounted && isConnected && !isSubscribed && (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<button <button
className="btn amber" className="btn amber"
@@ -187,12 +189,12 @@ export default function BotPanel({ performance }: Props) {
)} )}
</div> </div>
)} )}
{isConnected && isSubscribed && !hlApiKeySet && ( {mounted && isConnected && isSubscribed && !hlApiKeySet && (
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'oklch(75% 0.01 85)', padding: '6px 0' }}> <div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'oklch(75% 0.01 85)', padding: '6px 0' }}>
Paste your Hyperliquid API key below to finish setup Paste your Hyperliquid API key below to finish setup
</div> </div>
)} )}
{isConnected && isSubscribed && hlApiKeySet && ( {mounted && isConnected && isSubscribed && hlApiKeySet && (
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'var(--amber)', padding: '6px 0', fontWeight: 500 }}> <div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'var(--amber)', padding: '6px 0', fontWeight: 500 }}>
Setup saved · verification still depends on backend Setup saved · verification still depends on backend
</div> </div>
@@ -201,7 +203,7 @@ export default function BotPanel({ performance }: Props) {
</div> </div>
{/* HL API Key card — only when subscribed */} {/* HL API Key card — only when subscribed */}
{isConnected && isSubscribed && ( {mounted && isConnected && isSubscribed && (
<div className="card" style={{ padding: 20 }}> <div className="card" style={{ padding: 20 }}>
<div className="section-title"> <div className="section-title">
<h2 style={{ fontSize: 14 }}>Hyperliquid API key</h2> <h2 style={{ fontSize: 14 }}>Hyperliquid API key</h2>
-3
View File
@@ -87,7 +87,6 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
seriesRef.current = series seriesRef.current = series
// eslint-disable-next-line @typescript-eslint/no-explicit-any
chart.subscribeClick((param: any) => { chart.subscribeClick((param: any) => {
if (!param.time) return if (!param.time) return
const clickTime = typeof param.time === 'number' ? param.time : 0 const clickTime = typeof param.time === 'number' ? param.time : 0
@@ -164,7 +163,6 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
seriesRef.current = null seriesRef.current = null
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// Update candles + markers // Update candles + markers
@@ -249,7 +247,6 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
// the last candle's bucket so the bar grows in place; high/low expand if // the last candle's bucket so the bar grows in place; high/low expand if
// the live tick exceeds them. // the live tick exceeds them.
useEffect(() => { useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const series = seriesRef.current as any const series = seriesRef.current as any
if (!series) return if (!series) return
const live = livePrices[asset] const live = livePrices[asset]
+77 -5
View File
@@ -49,13 +49,41 @@ function LocalDateTime({ iso, opts }: { iso: string; opts?: Intl.DateTimeFormatO
return <span>{new Date(iso).toLocaleString('en-US', opts)}</span> return <span>{new Date(iso).toLocaleString('en-US', opts)}</span>
} }
function SourceIcon({ source: _source }: { source: string }) { // Visual identity per signal source. The post stream now mixes Trump posts
// Truth Social only — no X/Twitter support. // with scanner-emitted technical signals — operators need to tell them
return <div className="src-ico truth">T</div> // apart at a glance without reading the text.
//
// When adding a new scanner source, register it here too — otherwise it
// falls through to the generic "first letter" fallback which has no title
// or accent colour.
const SOURCE_DISPLAY: Record<string, { glyph: string; cls: string; title: string }> = {
truth: { glyph: 'T', cls: 'truth', title: 'Trump · Truth Social' },
breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' },
vcp_breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' },
reversal: { glyph: '⇋', cls: 'reversal', title: 'Reversal scanner' },
btc_bottom_reversal: { glyph: '₿', cls: 'reversal', title: 'BTC · Macro Bottom Reversal' },
funding_reversal: { glyph: 'ƒ', cls: 'reversal', title: 'BTC · Funding Rate Reversal' },
kol_divergence: { glyph: '⚖', cls: 'whale', title: 'KOL · Talks vs Trades Divergence' },
whale: { glyph: '🐋', cls: 'whale', title: 'On-chain whale alert' },
manual: { glyph: '✋', cls: 'manual', title: 'Manual entry' },
}
function SourceIcon({ source }: { source: string }) {
const d = SOURCE_DISPLAY[source.toLowerCase()]
if (d) {
return <div className={`src-ico ${d.cls}`} title={d.title}>{d.glyph}</div>
}
// Unknown source — show first letter so user can still tell it apart.
return <div className="src-ico external" title={`Source: ${source}`}>
{source.charAt(0).toUpperCase()}
</div>
} }
function SignalPill({ signal }: { signal: string | null }) { function SignalPill({ signal }: { signal: string | null }) {
if (!signal || signal === 'hold') return <span className="sig hold">HOLD</span> if (!signal || signal === 'hold') return <span className="sig hold">HOLD</span>
if (signal === 'buy') return <span className="sig buy">BUY</span>
if (signal === 'short') return <span className="sig short">SHORT</span>
if (signal === 'sell') return <span className="sig sell">SELL</span>
return <span className={`sig ${signal}`}>{signal.toUpperCase()}</span> return <span className={`sig ${signal}`}>{signal.toUpperCase()}</span>
} }
@@ -84,7 +112,11 @@ export default function PostRow({ post, selected, onClick }: PostRowProps) {
<SourceIcon source={post.source} /> <SourceIcon source={post.source} />
<div className="post-body"> <div className="post-body">
<div className="meta"> <div className="meta">
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>@realDonaldTrump</span> {/* Author label depends on source — non-Trump signals come from
technical scanners or external modules, not @realDonaldTrump. */}
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>
{post.source === 'truth' ? '@realDonaldTrump' : post.source}
</span>
<span>·</span> <span>·</span>
<TimeAgo iso={post.published_at} suffix=" ago" /> <TimeAgo iso={post.published_at} suffix=" ago" />
<span>·</span> <span>·</span>
@@ -98,6 +130,19 @@ export default function PostRow({ post, selected, onClick }: PostRowProps) {
</div> </div>
<div className="post-aside"> <div className="post-aside">
<SignalPill signal={post.signal} /> <SignalPill signal={post.signal} />
{/* Target asset chip for actionable signals */}
{post.target_asset && (post.signal === 'buy' || post.signal === 'short') && (
<span style={{
fontSize: 10, fontWeight: 700, fontFamily: 'var(--mono)',
padding: '2px 6px', borderRadius: 4,
background: post.signal === 'buy' ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
color: post.signal === 'buy' ? '#22c55e' : '#ef4444',
letterSpacing: '0.04em',
}}>
{post.target_asset}
{post.expected_move_pct ? ` +${post.expected_move_pct}%` : ''}
</span>
)}
<div className="impact-mini"> <div className="impact-mini">
{impact ? ( {impact ? (
<> <>
@@ -134,6 +179,33 @@ export default function PostRow({ post, selected, onClick }: PostRowProps) {
</div> </div>
</div> </div>
{/* Trade routing — target asset + expected move */}
{post.target_asset && (post.signal === 'buy' || post.signal === 'short') && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
<span className="tiny">Trade</span>
<span style={{
fontSize: 12, fontWeight: 700, fontFamily: 'var(--mono)',
padding: '3px 8px', borderRadius: 5,
background: post.signal === 'buy' ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
color: post.signal === 'buy' ? '#22c55e' : '#ef4444',
}}>
{post.signal === 'buy'
? `↑ LONG ${post.target_asset}`
: `↓ SHORT ${post.target_asset}`}
</span>
{post.expected_move_pct != null && (
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{`AI expects ~${post.expected_move_pct}% in 1h`}
</span>
)}
{post.category && (
<span style={{ fontSize: 10, color: 'var(--ink-4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{post.category.replace(/_/g, ' ')}
</span>
)}
</div>
)}
{/* AI reasoning */} {/* AI reasoning */}
{post.ai_reasoning && ( {post.ai_reasoning && (
<div> <div>
@@ -147,7 +219,7 @@ export default function PostRow({ post, selected, onClick }: PostRowProps) {
{/* Price impact — peak move in signal direction per window */} {/* Price impact — peak move in signal direction per window */}
{impact && ( {impact && (
<div> <div>
<div className="tiny" style={{ marginBottom: 8 }}>Peak move · {impact.asset}</div> <div className="tiny" style={{ marginBottom: 8 }}>{`Peak move · ${impact.asset}`}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{(['m5', 'm15', 'm1h'] as const).map(key => { {(['m5', 'm15', 'm1h'] as const).map(key => {
const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h' const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h'
+279
View File
@@ -0,0 +1,279 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLocale } from 'next-intl'
import { useWsSubscribe } from '@/lib/wsContext'
const API_BASE = '/api/proxy/api'
interface SignalAlert {
type: 'funding_signal'
symbol: string
time: string
close: number
tbr: number
vol_mult: number
bb_pct: number
bb_upper: number
btc_trend: string
enabled: boolean
}
function symbolLabel(s: string) {
return s.replace('USDT', '')
}
function timeAgo(iso: string) {
const diff = (Date.now() - new Date(iso).getTime()) / 1000
if (diff < 60) return `${Math.round(diff)}s ago`
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
return `${Math.round(diff / 3600)}h ago`
}
// ── iOS-style toggle switch ───────────────────────────────────────────────────
function ToggleSwitch({ on, loading, onToggle }: {
on: boolean
loading: boolean
onToggle: () => void
}) {
return (
<button
onClick={onToggle}
disabled={loading}
title={on ? 'Click to disable' : 'Click to enable'}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'none',
border: 'none',
cursor: loading ? 'not-allowed' : 'pointer',
padding: 0,
opacity: loading ? 0.5 : 1,
}}
>
{/* Switch track */}
<div style={{
width: 44,
height: 26,
borderRadius: 13,
background: on ? '#22c55e' : 'var(--ink-4)',
position: 'relative',
transition: 'background 0.2s',
flexShrink: 0,
opacity: on ? 1 : 0.5,
}}>
{/* Thumb */}
<div style={{
width: 18,
height: 18,
borderRadius: '50%',
background: '#fff',
position: 'absolute',
top: 2,
left: on ? 20 : 2,
transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}} />
</div>
<span style={{
fontSize: 12,
fontWeight: 600,
color: on ? '#22c55e' : 'var(--ink-3)',
letterSpacing: '0.05em',
minWidth: 24,
}}>
{on ? 'ON' : 'OFF'}
</span>
</button>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export default function SignalMonitor() {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const [enabled, setEnabledState] = useState(false)
const [loading, setLoading] = useState(false)
const [backendOk, setBackendOk] = useState<boolean | null>(null)
const [signals, setSignals] = useState<SignalAlert[]>([])
const [btcTrend, setBtcTrend] = useState<string | null>(null)
const [lastScan, setLastScan] = useState<Date | null>(null)
// ── Load initial state ───────────────────────────────────────────────────
useEffect(() => {
fetch(`${API_BASE}/signal/status`)
.then(r => { if (!r.ok) throw new Error(); return r.json() })
.then(d => { setEnabledState(d.enabled); setBackendOk(true) })
.catch(() => setBackendOk(false))
fetch(`${API_BASE}/signal/history?limit=20`)
.then(r => r.json())
.then((list: SignalAlert[]) => {
if (!Array.isArray(list)) return
setSignals(list)
if (list.length > 0) {
setBtcTrend(list[0].btc_trend)
setLastScan(new Date(list[0].time))
}
})
.catch(() => {})
}, [])
// ── WebSocket listener (shared singleton connection via WsProvider) ─────────
useWsSubscribe('funding_signal', (msg) => {
const alert = msg as SignalAlert
setBtcTrend(alert.btc_trend)
setLastScan(new Date(alert.time))
setSignals(prev => [alert, ...prev].slice(0, 50))
})
// ── Toggle ───────────────────────────────────────────────────────────────
const toggle = useCallback(async () => {
setLoading(true)
try {
const next = !enabled
const r = await fetch(`${API_BASE}/signal/toggle?enabled=${next}`, { method: 'POST' })
if (!r.ok) throw new Error()
const d = await r.json()
setEnabledState(d.enabled)
setBackendOk(true)
} catch {
setBackendOk(false)
}
setLoading(false)
}, [enabled])
// ── Render ───────────────────────────────────────────────────────────────
const btcUp = btcTrend?.includes('↑')
return (
<div className="card" style={{ padding: 20, marginTop: 12 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 3 }}>
{isZh ? '突破监控' : 'Breakout Monitor'}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{isZh ? 'ETH · LINK · 5 分钟扫描' : 'ETH · LINK · 5m scan'}
</div>
</div>
<ToggleSwitch on={enabled} loading={loading} onToggle={toggle} />
</div>
{/* Backend offline warning */}
{backendOk === false && (
<div style={{
fontSize: 11, color: '#f59e0b',
padding: '6px 10px', borderRadius: 6,
background: 'rgba(245,158,11,0.1)',
marginBottom: 12,
}}>
{isZh ? '后端离线,开关暂时不可用' : "Backend offline — toggle won't work"}
</div>
)}
{/* Status row: BTC trend + last scan */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
fontSize: 11, color: 'var(--ink-3)',
padding: '6px 10px', borderRadius: 8,
background: 'rgba(255,255,255,0.04)',
marginBottom: 14,
}}>
<span>
BTC &nbsp;
{btcTrend ? (
<strong style={{ color: btcUp ? '#22c55e' : '#ef4444' }}>{btcTrend}</strong>
) : (
<span style={{ opacity: 0.5 }}></span>
)}
</span>
<span>
{lastScan
? (isZh ? `最近信号 ${timeAgo(lastScan.toISOString())}` : `Last signal ${timeAgo(lastScan.toISOString())}`)
: enabled ? (isZh ? '扫描中…' : 'Scanning…') : (isZh ? '已暂停' : 'Paused')
}
</span>
</div>
{/* Signal list */}
{signals.length === 0 ? (
<div style={{
fontSize: 13, color: 'var(--ink-3)',
textAlign: 'center', padding: '20px 0',
lineHeight: 1.5,
}}>
{enabled
? (isZh ? '· 正在等待信号…' : '· Watching for signals…')
: (isZh ? '· 打开开关后开始监控' : '· Enable the toggle to start watching')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{signals.map((s) => (
<div key={`${s.symbol}-${s.time}`} style={{
padding: '11px 13px',
borderRadius: 10,
background: 'rgba(255,255,255,0.04)',
borderLeft: `3px solid ${s.enabled ? '#22c55e' : 'rgba(255,255,255,0.15)'}`,
}}>
{/* Top row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
fontWeight: 700, fontSize: 14,
fontFamily: 'var(--mono)',
color: 'var(--ink)',
}}>
{symbolLabel(s.symbol)}
</span>
{!s.enabled && (
<span style={{
fontSize: 9, padding: '2px 6px', borderRadius: 4,
background: 'rgba(255,255,255,0.08)',
color: 'var(--ink-3)',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
silent
</span>
)}
</div>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{timeAgo(s.time)}
</span>
</div>
{/* Metrics grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
{[
{ label: isZh ? '价格' : 'Price', value: `$${s.close.toLocaleString(undefined, { maximumFractionDigits: 2 })}`, color: 'var(--ink)' },
{ label: isZh ? '主动买入' : 'Taker Buy', value: `${(s.tbr * 100).toFixed(1)}%`, color: '#22c55e' },
{ label: isZh ? '成交量倍数' : 'Vol ×', value: `${s.vol_mult}×`, color: '#22c55e' },
].map(m => (
<div key={m.label} style={{
background: 'rgba(255,255,255,0.03)',
borderRadius: 6, padding: '5px 8px',
}}>
<div style={{ fontSize: 9, color: 'var(--ink-3)', marginBottom: 2, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{m.label}
</div>
<div style={{ fontSize: 13, fontFamily: 'var(--mono)', fontWeight: 600, color: m.color }}>
{m.value}
</div>
</div>
))}
</div>
{/* Footer */}
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 8 }}>
{isZh ? `BB squeeze 处于第 ${s.bb_pct} 分位` : `BB squeeze ${s.bb_pct}th pct`} &nbsp;·&nbsp; {s.btc_trend}
</div>
</div>
))}
</div>
)}
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
'use client'
import { useMemo } from 'react'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { defaultLocale, locales, type Locale } from '@/i18n'
function replaceLocale(pathname: string, nextLocale: Locale) {
const segments = pathname.split('/').filter(Boolean)
const current = segments[0]
if (locales.includes(current as Locale)) {
segments[0] = nextLocale
} else {
segments.unshift(nextLocale)
}
return '/' + segments.join('/')
}
export default function LanguageSwitch() {
const pathname = usePathname()
const searchParams = useSearchParams()
const activeLocale = useMemo<Locale>(() => {
const seg = pathname.split('/').filter(Boolean)[0]
return locales.includes(seg as Locale) ? (seg as Locale) : defaultLocale
}, [pathname])
return (
<div
aria-label="Language switch"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: 4,
borderRadius: 999,
background: 'var(--bg-sunk)',
border: '1px solid var(--line)',
flexShrink: 0,
}}
>
{([
['en', 'EN'],
['zh', '中文'],
] as const).map(([locale, label]) => {
const active = activeLocale === locale
const nextPath = replaceLocale(pathname || `/${defaultLocale}`, locale)
const qs = searchParams.toString()
const href = qs ? `${nextPath}?${qs}` : nextPath
return (
<Link
key={locale}
href={href}
aria-pressed={active}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: locale === 'zh' ? 48 : 40,
padding: '6px 10px',
borderRadius: 999,
border: 'none',
background: active ? 'var(--ink)' : 'transparent',
color: active ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 12,
fontWeight: 700,
lineHeight: 1,
cursor: 'pointer',
textDecoration: 'none',
}}
>
{label}
</Link>
)
})}
</div>
)
}
+53 -19
View File
@@ -4,6 +4,9 @@ import { useState, useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useAccount, useConnect, useDisconnect } from 'wagmi' import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { useTranslations } from 'next-intl'
// i18n shelved — LanguageSwitch hidden but kept on disk for future revival.
// import LanguageSwitch from './LanguageSwitch'
function BrandMark() { function BrandMark() {
return <span className="brand-mark">α</span> return <span className="brand-mark">α</span>
@@ -43,28 +46,59 @@ function ThemeToggle() {
} }
export default function Navbar() { export default function Navbar() {
const t = useTranslations('nav')
const pathname = usePathname() const pathname = usePathname()
const { address, isConnected } = useAccount() const { address, isConnected } = useAccount()
const { connect, connectors } = useConnect() const { connect, connectors } = useConnect()
const { disconnect } = useDisconnect() const { disconnect } = useDisconnect()
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const [walletMenuOpen, setWalletMenuOpen] = useState(false) const [walletMenuOpen, setWalletMenuOpen] = useState(false)
const [copied, setCopied] = useState(false)
async function copyAddress(addr: string) {
let ok = false
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(addr)
ok = true
} else {
// Fallback for non-secure contexts where the Clipboard API is absent.
const ta = document.createElement('textarea')
ta.value = addr
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
ok = document.execCommand('copy')
document.body.removeChild(ta)
}
} catch { ok = false }
setCopied(ok)
setTimeout(() => setCopied(false), 1500)
}
useEffect(() => { setMounted(true) }, []) useEffect(() => { setMounted(true) }, [])
useEffect(() => { setWalletMenuOpen(false) }, [pathname, address]) useEffect(() => { setWalletMenuOpen(false) }, [pathname, address])
const locale = pathname.split('/')[1] || 'en' const locale = pathname.split('/')[1] || 'en'
const path = '/' + pathname.split('/').slice(2).join('/') const path = '/' + pathname.split('/').slice(2).join('/')
// Two independent trading systems get their own top-level tabs — no
// intermediate "Signals" hub. Trump = event scalp; BTC = reversal.
const tabs = [ const tabs = [
{ key: '/', label: 'Overview' }, { key: '/', label: t('tabs.overview') },
{ key: '/posts', label: 'Signals' }, { key: '/trump', label: t('tabs.trump') },
{ key: '/trades', label: 'Trades' }, { key: '/btc', label: t('tabs.btc') },
{ key: '/analytics', label: 'Analytics' }, { key: '/kol', label: t('tabs.kol') },
{ key: '/settings', label: 'Settings' }, { key: '/trades', label: t('tabs.trades') },
{ key: '/analytics', label: t('tabs.analytics') },
{ key: '/settings', label: t('tabs.settings') },
] ]
function isActive(key: string) { function isActive(key: string) {
if (key === '/') return path === '/' || path === '//' if (key === '/') return path === '/' || path === '//'
// /posts (legacy hub) still resolves but isn't a tab; treat it + /archive
// as part of the Trump section so a tab stays highlighted there.
if (key === '/trump') return path.startsWith('/trump') || path.startsWith('/posts') || path.startsWith('/archive')
return path.startsWith(key) return path.startsWith(key)
} }
@@ -72,19 +106,19 @@ export default function Navbar() {
return ( return (
<nav className="nav"> <nav className="nav">
<div className="brand"> <Link href={`/${locale}`} className="brand" style={{ textDecoration: 'none', color: 'inherit' }}>
<BrandMark /> <BrandMark />
<span>Trump Alpha</span> <span>{t('brand')}</span>
</div> </Link>
<div className="nav-tabs"> <div className="nav-tabs">
{tabs.map(t => ( {tabs.map(tab => (
<Link <Link
key={t.key} key={tab.key}
href={`/${locale}${t.key === '/' ? '' : t.key}`} href={`/${locale}${tab.key === '/' ? '' : tab.key}`}
className={`nav-tab ${isActive(t.key) ? 'active' : ''}`} className={`nav-tab ${isActive(tab.key) ? 'active' : ''}`}
> >
{t.label} {tab.label}
</Link> </Link>
))} ))}
</div> </div>
@@ -92,9 +126,10 @@ export default function Navbar() {
<div className="nav-spacer" /> <div className="nav-spacer" />
<div className="nav-right"> <div className="nav-right">
{/* <LanguageSwitch /> — shelved until i18n coverage is complete */}
<ThemeToggle /> <ThemeToggle />
{!mounted ? ( {!mounted ? (
<button className="connect-btn lg" suppressHydrationWarning>Connect wallet</button> <button className="connect-btn lg" suppressHydrationWarning>{t('actions.connectWallet')}</button>
) : isConnected && shortAddr ? ( ) : isConnected && shortAddr ? (
<div className="wallet-menu-wrap" style={{ position: 'relative' }}> <div className="wallet-menu-wrap" style={{ position: 'relative' }}>
<button <button
@@ -116,11 +151,10 @@ export default function Navbar() {
role="menuitem" role="menuitem"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault() e.preventDefault()
if (address) navigator.clipboard?.writeText(address) if (address) copyAddress(address)
setWalletMenuOpen(false)
}} }}
> >
Copy address {copied ? t('actions.copied') : t('actions.copyAddress')}
</button> </button>
<button <button
role="menuitem" role="menuitem"
@@ -131,7 +165,7 @@ export default function Navbar() {
setWalletMenuOpen(false) setWalletMenuOpen(false)
}} }}
> >
Disconnect {t('actions.disconnect')}
</button> </button>
</div> </div>
</div> </div>
@@ -143,7 +177,7 @@ export default function Navbar() {
if (connector) connect({ connector }) if (connector) connect({ connector })
}} }}
> >
Connect wallet {t('actions.connectWallet')}
</button> </button>
)} )}
</div> </div>
+473
View File
@@ -0,0 +1,473 @@
'use client'
/**
* Open positions panel — what's currently on the book.
*
* The single most-asked question on any trading dashboard: "what do I hold
* right now?" Polls /api/positions/open + /api/positions/today every 15s.
* Compact enough to embed at the top of Dashboard AND Trades.
*
* Empty state is intentional: explicitly says "0 open" rather than hiding,
* so the user can confirm the bot isn't doing anything weird off-screen.
*/
import { useEffect, useState } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import {
getOpenPositions,
getTodayStats,
manualCloseTrade,
setTradeGrow,
type OpenPosition,
type TodayStats,
} from '@/lib/api'
import { getCachedViewEnvelope, getOrCreateViewEnvelope, signRequest } from '@/lib/signedRequest'
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
const POLL_MS = 15_000
function fmtMoney(n: number | null | undefined, opts: { sign?: boolean } = {}) {
if (n == null || isNaN(n)) return '—'
const sign = opts.sign === true
const abs = Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (n < 0) return '-$' + abs
if (sign && n > 0) return '+$' + abs
return '$' + abs
}
function fmtPct(n: number | null | undefined) {
if (n == null || isNaN(n)) return '—'
return (n >= 0 ? '+' : '') + n.toFixed(2) + '%'
}
function fmtHold(min: number) {
if (min < 60) return `${min}m`
const h = Math.floor(min / 60), m = min % 60
if (h < 24) return `${h}h ${m}m`
return `${Math.floor(h / 24)}d ${h % 24}h`
}
function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
p: OpenPosition
onClose: (p: OpenPosition) => void
onToggleGrow: (p: OpenPosition) => void
growBusy: boolean
isZh: boolean
}) {
const pnlTone = (p.unrealized_pct ?? 0) > 0 ? 'up'
: (p.unrealized_pct ?? 0) < 0 ? 'down' : 'idle'
const pnlColor = pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-3)'
return (
<div style={{
display: 'grid',
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
gap: 12, alignItems: 'center',
padding: '10px 14px',
borderTop: '1px solid var(--line)',
fontSize: 13,
}}>
{/* Asset + paper tag */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={`asset-dot ${p.asset.toLowerCase()}`} />
<span style={{ fontWeight: 600 }}>{p.asset}</span>
{p.is_paper && (
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 3,
background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
}}>P</span>
)}
</div>
{/* Side */}
<span className={`side-pill ${p.side}`}>
{p.side === 'long'
? '↗ LONG'
: '↘ SHORT'}
</span>
{/* Entry → Current */}
<div className="mono" style={{ fontSize: 12 }}>
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry mark</div>
<div>
{fmtMoney(p.entry_price)}
<span style={{ color: 'var(--ink-4)' }}> </span>
{p.current_price != null ? fmtMoney(p.current_price) : <span style={{ color: 'var(--ink-4)' }}>n/a</span>}
</div>
</div>
{/* Size + leverage (size = OPEN notional after de-risk) */}
<div className="mono" style={{ fontSize: 12 }}>
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>open size · lev</div>
<div>
{p.size_usd != null ? '$' + p.size_usd.toFixed(0) : '—'}
{p.leverage != null && <span style={{ color: 'var(--ink-4)' }}> · {p.leverage}×</span>}
</div>
{((p.derisk_steps ?? 0) > 0 || (p.addon_steps ?? 0) > 0) && (
<div style={{ fontSize: 9, marginTop: 2 }}>
{(p.addon_steps ?? 0) > 0 && (
<span style={{ color: 'var(--up)' }}>{`⬆ pyramided ×${p.addon_steps} `}</span>
)}
{(p.derisk_steps ?? 0) > 0 && (
<span style={{ color: 'var(--down)' }}>{`⬇ de-risked ×${p.derisk_steps}`}</span>
)}
</div>
)}
{/* Per-trade Grow (pyramiding) switch */}
<button
onClick={() => onToggleGrow(p)}
disabled={growBusy}
title={p.grow_mode
? (isZh ? 'Grow 已开启:趋势确认后会继续顺势加仓。点击关闭。' : 'Grow ON — scales INTO this winner on confirmed trend. Click to turn off.')
: (isZh ? 'Grow 已关闭:仅持有并做保护性降风险。点击后允许顺势加仓。' : 'Grow OFF — hold + protective de-risk only. Click to let it pyramid.')}
style={{
marginTop: 4, fontSize: 9, fontWeight: 700, padding: '2px 7px',
borderRadius: 999, cursor: growBusy ? 'wait' : 'pointer',
border: '1px solid var(--line)',
background: p.grow_mode ? 'var(--up, #16a34a)' : 'transparent',
color: p.grow_mode ? '#fff' : 'var(--ink-3)',
}}
>
{p.grow_mode ? '⬆ Grow ON' : 'Grow OFF'}
</button>
</div>
{/* Hold time */}
<div className="mono" style={{ fontSize: 12, color: 'var(--ink-2)' }}>
{fmtHold(p.hold_minutes)}
</div>
{/* Unrealized PnL */}
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: pnlColor, fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(p.unrealized_usd, { sign: true })}
</div>
<div style={{ fontSize: 11, color: pnlColor, opacity: 0.85 }}>
{fmtPct(p.unrealized_pct)}
</div>
{p.realized_usd != null && p.realized_usd !== 0 && (
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
banked {fmtMoney(p.realized_usd, { sign: true })}
</div>
)}
</div>
{/* Emergency close — bypasses TP/SL/schedule. Always available. */}
<button
onClick={() => onClose(p)}
className="btn ghost"
style={{
padding: '6px 10px', fontSize: 11, fontWeight: 600,
color: 'var(--down)',
border: '1px solid color-mix(in oklab, var(--down) 25%, var(--line))',
}}
title={isZh ? '立即平掉这个仓位' : 'Close this position immediately'}
>
{isZh ? '立即平仓' : 'Close now'}
</button>
</div>
)
}
export default function OpenPositions() {
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 [mounted, setMounted] = useState(false)
const [positions, setPositions] = useState<OpenPosition[] | null>(null)
const [today, setToday] = useState<TodayStats | null>(null)
const [err, setErr] = useState<string | null>(null)
// Close-confirmation modal state
const [closing, setClosing] = useState<OpenPosition | null>(null)
const [closeState, setCloseState] = useState<'idle'|'signing'|'closing'|'err'>('idle')
useEffect(() => { setMounted(true) }, [])
const [closeErr, setCloseErr] = useState('')
const [growId, setGrowId] = useState<number | null>(null)
async function toggleGrow(p: OpenPosition) {
if (!address || growId !== null) return
const next = !p.grow_mode
setGrowId(p.trade_id)
try {
const env = await signRequest({
action: 'set_trade_grow', wallet: address,
body: { trade_id: p.trade_id, enabled: next }, signMessageAsync,
})
const r = await setTradeGrow(env, p.trade_id, next)
setPositions(prev => (prev ?? []).map(x =>
x.trade_id === p.trade_id ? { ...x, grow_mode: r.grow_mode } : x))
} catch (e) {
if (isUserRejection(e)) {
setErr(isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled')
} else {
setErr(walletErrorLabel(e, isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled', 90)
|| (isZh ? 'Grow 切换失败' : 'grow toggle failed'))
}
} finally { setGrowId(null) }
}
useEffect(() => {
if (!isConnected || !address) {
setPositions(null); setToday(null)
return
}
let cancelled = false
// First load uses getOrCreateViewEnvelope — may pop MetaMask exactly ONCE
// when the page is opened (user action). All subsequent polls reuse the
// cached envelope via getCachedViewEnvelope (no popup). When the cache
// expires (20 min), the polling tick silently skips until the user
// refocuses the tab or navigates back in — preventing the wallet from
// popping in the background while the user is doing something else.
async function load(envSource: 'first' | 'poll') {
const env = envSource === 'first'
? await getOrCreateViewEnvelope({
action: 'view_positions', wallet: address!, signMessageAsync,
})
: getCachedViewEnvelope('view_positions', address!)
if (!env) return // cache expired during polling — wait for user re-entry
try {
const [p, t] = await Promise.all([
getOpenPositions(address!, env),
getTodayStats(address!, env),
])
if (!cancelled) {
setPositions(p.positions); setToday(t); setErr(null)
}
} catch (e: unknown) {
if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error'))
}
}
load('first').catch(e => {
// Initial sign-rejection lands here. Show a soft error so the polling
// loop still starts (a subsequent in-cache sign elsewhere will revive it).
if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error'))
})
const id = setInterval(() => { void load('poll') }, POLL_MS)
return () => { cancelled = true; clearInterval(id) }
}, [address, isConnected, signMessageAsync, isZh])
// Trigger close. Two-step: opens modal, user confirms, we sign + POST.
async function confirmCloseTrade() {
if (!address || !closing) return
setCloseErr(''); setCloseState('signing')
try {
const env = await signRequest({
action: 'close_trade',
wallet: address,
body: { trade_id: closing.trade_id },
signMessageAsync,
})
setCloseState('closing')
await manualCloseTrade(env, closing.trade_id)
// Optimistically drop the row from the list; the next poll will confirm.
setPositions(prev => (prev ?? []).filter(x => x.trade_id !== closing.trade_id))
setClosing(null)
setCloseState('idle')
} catch (e: unknown) {
setCloseErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120))
setCloseState('err')
}
}
// Return null both before mount (SSR) and when not connected — same output,
// no hydration mismatch.
if (!mounted || !isConnected) return null
if (positions === null && today === null) return null
const totalUnrealized = (positions ?? []).reduce(
(s, p) => s + (p.unrealized_usd ?? 0), 0,
)
const pnlTone = today
? (today.realized_pnl_usd > 0 ? 'up' : today.realized_pnl_usd < 0 ? 'down' : 'idle')
: 'idle'
const todayColor = pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-2)'
return (
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
{/* Header strip — at-a-glance status */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 18px', flexWrap: 'wrap', gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 18, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
Open
</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>
{today?.open_count ?? 0}
<span style={{ fontSize: 12, color: 'var(--ink-3)', marginLeft: 6, fontWeight: 400 }}>
{`position${(today?.open_count ?? 0) === 1 ? '' : 's'}`}
</span>
</div>
</div>
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
Unrealized
</div>
<div style={{ fontSize: 18, fontWeight: 700,
color: totalUnrealized > 0 ? 'var(--up)' :
totalUnrealized < 0 ? 'var(--down)' : 'var(--ink-2)',
fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(totalUnrealized, { sign: true })}
</div>
</div>
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
Today realised
</div>
<div style={{ fontSize: 18, fontWeight: 700, color: todayColor,
fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(today?.realized_pnl_usd ?? 0, { sign: true })}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
{today ? `${today.trades_closed} closed · ${today.wins}W · ${today.losses}L` : '—'}
</div>
{today != null && (today.open_realized_usd ?? 0) !== 0 && (
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} banked on open`}
</div>
)}
</div>
</div>
{err && (
<span style={{ fontSize: 11, color: 'var(--down)' }}> {err}</span>
)}
</div>
{/* Position list (or empty state) */}
{positions && positions.length > 0 ? (
<div>
<div style={{
display: 'grid',
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
gap: 12,
padding: '8px 14px',
background: 'var(--bg-sunk)',
fontSize: 10, fontWeight: 600, letterSpacing: '0.05em',
color: 'var(--ink-3)', textTransform: 'uppercase',
}}>
<span>Asset</span><span>Side</span><span>Prices</span>
<span>Size</span><span>Hold</span>
<span style={{ textAlign: 'right' }}>Unrealized</span>
<span style={{ textAlign: 'right' }}>Action</span>
</div>
{positions.map(p => (
<PositionRow
key={p.trade_id}
p={p}
onClose={setClosing}
onToggleGrow={toggleGrow}
growBusy={growId === p.trade_id}
isZh={isZh}
/>
))}
</div>
) : (
<div style={{
borderTop: '1px solid var(--line)',
padding: '20px 18px',
fontSize: 13, color: 'var(--ink-3)', textAlign: 'center',
}}>
No open positions. The bot will appear here as soon as a signal triggers a trade.
</div>
)}
{/* ── Confirmation modal (P0.1 safety valve) ───────────────────
Two-step UX so the wallet popup isn't surprising. The user
presses "Close now", reads the impact summary, then confirms. */}
{closing && (
<div
onClick={() => closeState === 'idle' && setClosing(null)}
style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20,
}}
>
<div
onClick={e => e.stopPropagation()}
className="card"
style={{ padding: 24, maxWidth: 440, width: '100%' }}
>
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>
{isZh
? `现在平掉 ${closing.asset} ${closing.side === 'long' ? '多单' : '空单'}`
: `Close ${closing.side === 'long' ? 'long' : 'short'} ${closing.asset} now?`}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 16 }}>
{closing.is_paper
? (isZh ? '这是模拟交易,平仓只做本地记录,不会调用 Hyperliquid。' : 'Paper trade — synthetic close, no Hyperliquid call.')
: (isZh ? '会向 Hyperliquid 发送 IOC 市价单。已实现盈亏将立即确认。' : 'Sends an IOC market order to Hyperliquid. Realised PnL is permanent.')}
</div>
<div style={{
background: 'var(--bg-sunk)', borderRadius: 8, padding: 14, marginBottom: 16,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '开仓价' : 'Entry'}</span>
<span className="mono">{fmtMoney(closing.entry_price)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Mark'}</span>
<span className="mono">{closing.current_price != null ? fmtMoney(closing.current_price) : (isZh ? '暂无' : 'n/a')}</span>
</div>
{closing.realized_usd != null && closing.realized_usd !== 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6, color: 'var(--ink-3)' }}>
<span>{isZh ? '已锁定(降风险)' : 'Already banked (de-risk)'}</span>
<span className="mono">{fmtMoney(closing.realized_usd, { sign: true })}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, fontWeight: 600, marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--line)' }}>
<span>{isZh ? '当前未平部分预估盈亏' : 'Est. PnL on open portion'}</span>
<span style={{
color: (closing.unrealized_usd ?? 0) > 0 ? 'var(--up)'
: (closing.unrealized_usd ?? 0) < 0 ? 'var(--down)' : 'var(--ink-2)',
fontVariantNumeric: 'tabular-nums',
}}>
{fmtMoney(closing.unrealized_usd, { sign: true })}
<span style={{ marginLeft: 6, opacity: 0.7, fontSize: 11 }}>
({fmtPct(closing.unrealized_pct)})
</span>
</span>
</div>
</div>
{closeState === 'err' && (
<div style={{ fontSize: 12, color: 'var(--down)', marginBottom: 12 }}>{closeErr}</div>
)}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setClosing(null)}
disabled={closeState === 'signing' || closeState === 'closing'}
className="btn ghost"
style={{ padding: '8px 16px', fontSize: 13 }}
>
{isZh ? '取消' : 'Cancel'}
</button>
<button
onClick={confirmCloseTrade}
disabled={closeState === 'signing' || closeState === 'closing'}
className="btn"
style={{
padding: '8px 18px', fontSize: 13, fontWeight: 600,
background: 'var(--down)', color: '#fff', border: 'none',
}}
>
{closeState === 'signing' ? (isZh ? '钱包签名中…' : 'Sign in wallet…')
: closeState === 'closing' ? (isZh ? '平仓中…' : 'Closing…')
: (isZh ? `立即平掉 ${closing.asset}` : `Close ${closing.asset} now`)}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
'use client'
import type { SignalSource } from '@/lib/api'
interface Props {
sources: SignalSource[]
activeSource: string
onChange: (src: string) => void
/** Count map keyed by source (computed by parent from live post list). */
liveCounts: Record<string, number>
}
function relativeTime(iso: string | null): string {
if (!iso) return '—'
const diff = (Date.now() - new Date(iso).getTime()) / 1000
if (diff < 60) return `${Math.round(diff)}s ago`
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
if (diff < 86400) return `${Math.round(diff / 3600)}h ago`
return `${Math.round(diff / 86400)}d ago`
}
/**
* Source filter chips for the signals page.
*
* Each chip = one entry from /api/signals/sources. Shows total historical
* count from the server + the (filtered) live count from the current post
* payload — so the user can see "this filter would drop me from 700 to 13".
*/
export default function SourceChips({ sources, activeSource, onChange, liveCounts }: Props) {
// Always have an "all" pseudo-source first
const totalCount = sources.reduce((s, x) => s + x.count, 0)
return (
<div className="card" style={{ padding: 16, marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase',
color: 'var(--ink-3)', marginBottom: 10 }}>
Signal sources
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{/* All */}
<button
onClick={() => onChange('all')}
className={`chip ${activeSource === 'all' ? 'up' : ''}`}
style={{ padding: '8px 14px', cursor: 'pointer', border: '1px solid var(--line)' }}
>
<span style={{ fontWeight: 600 }}>All</span>
<span style={{ marginLeft: 8, color: 'var(--ink-4)' }}>{totalCount}</span>
</button>
{sources.map(s => (
<button
key={s.source}
onClick={() => onChange(s.source)}
className={`chip ${activeSource === s.source ? 'up' : ''}`}
style={{
padding: '8px 14px', cursor: 'pointer',
border: '1px solid var(--line)',
background: activeSource === s.source ? 'var(--up-soft)' : undefined,
}}
title={`Last signal: ${relativeTime(s.latest)}`}
>
<span style={{ fontWeight: 600 }}>{s.source}</span>
<span style={{ marginLeft: 8, color: 'var(--ink-4)' }}>{s.count}</span>
{/* Show live filtered count when different from total */}
{liveCounts[s.source] !== undefined && liveCounts[s.source] !== s.count && (
<span style={{ marginLeft: 6, fontSize: 10, color: 'var(--ink-3)' }}>
({liveCounts[s.source]} loaded)
</span>
)}
<span style={{ marginLeft: 8, fontSize: 10, color: 'var(--ink-4)' }}>
· {relativeTime(s.latest)}
</span>
</button>
))}
</div>
</div>
)
}
+267
View File
@@ -0,0 +1,267 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import { getUserPublic, setAutoTrade, type UserPublic } from '@/lib/api'
import { signRequest } from '@/lib/signedRequest'
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
/**
* ONE control: the master Auto-Trade switch.
*
* OFF (default, safe) — signals are still scanned & shown in the feed,
* but NO trade is opened. Pure monitoring.
* ON — a qualifying signal auto-opens a trade (full
* System-2 risk: dynamic leverage, staged de-risk,
* ratchet, peak-trail). De-risk / stop-loss always
* run on open positions regardless — never toggleable.
*
* Replaces the old scanner-toggle / timed manual-window / schedule / paper
* trio. Circuit breaker is read-only; turning Auto-Trade ON acknowledges +
* clears a tripped breaker. Per-trade pyramiding is the "Grow" switch on
* each open position (Trades page), not here.
*/
const SYS = {
trump: { idx: '①', name: 'Trump', accent: '#b45309', soft: 'rgba(180,83,9,0.08)' },
btc: { idx: '②', name: 'BTC', accent: '#16a34a', soft: 'rgba(22,163,74,0.08)' },
} as const
const ROW: React.CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 12, padding: '8px 0', flexWrap: 'wrap',
}
const LABEL: React.CSSProperties = { fontSize: 13, color: 'var(--ink-2)' }
export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const s = SYS[system]
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const [mounted, setMounted] = useState(false)
const [pub, setPub] = useState<UserPublic | null>(null)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
useEffect(() => { setMounted(true) }, [])
const refresh = useCallback(async () => {
if (!address) { setPub(null); return }
const p = await getUserPublic(address.toLowerCase()).catch(() => null)
setPub(p)
}, [address])
useEffect(() => {
refresh()
const poll = setInterval(refresh, 30_000)
return () => clearInterval(poll)
}, [refresh])
const subscribed = !!pub?.active
const paper = !!pub?.paper_mode
const autoOn = !!pub?.auto_trade
const cbTripped = !!pub?.circuit_breaker_tripped_at &&
(Date.now() - new Date(pub!.circuit_breaker_tripped_at!).getTime()) < 24 * 3600 * 1000
async function flipAuto(on: boolean) {
if (busy) return
if (!address) { setErr(isZh ? '请先连接右上角的钱包。' : 'Connect your wallet first (top-right).'); return }
if (!subscribed) { setErr(isZh ? '钱包尚未订阅,请先去设置页完成订阅。' : 'Wallet not subscribed — subscribe on the Settings page first.'); return }
if (autoOn === on) return
// Show pre-signing confirmation sheet before triggering MetaMask
const confirmed = await confirmSign(on
? {
label: paper
? (isZh ? '开启 Auto-Trade(模拟模式)' : 'Enable Auto-Trade (paper mode)')
: (isZh ? '开启 Auto-Trade(真实资金)' : 'Enable Auto-Trade (live capital)'),
description: paper
? (isZh
? '机器人将在信号触发时自动开仓(Paper 模式,无真实资金)。钱包签名仅用于身份验证。'
: 'The bot will auto-open positions when a qualifying signal fires in paper mode. No real capital is used. The wallet signature is only for identity verification.')
: (isZh
? '机器人将在 Hyperliquid 上用真实资金自动开仓。止损与分级减仓始终生效保护仓位。'
: 'The bot will auto-open real positions on Hyperliquid. Stop-loss and staged de-risking remain active to protect the position.'),
danger: !paper,
}
: {
label: isZh ? '关闭 Auto-Trade' : 'Disable Auto-Trade',
description: isZh
? '停止自动开仓。已有持仓的止损与减仓逻辑仍会继续运行。'
: 'Stop opening new trades automatically. Risk controls on existing positions will keep running.',
danger: false,
}
)
if (!confirmed) return
setErr(''); setBusy(true)
try {
const env = await signRequest({
action: 'set_auto_trade', wallet: address,
body: { enabled: on }, signMessageAsync,
})
const r = await setAutoTrade(env, on)
setPub(p => p ? {
...p,
auto_trade: r.auto_trade,
...(r.circuit_breaker_cleared
? { circuit_breaker_tripped_at: null, circuit_breaker_reason: null }
: {}),
} : p)
} catch (e) {
if (isUserRejection(e)) {
setErr(isZh ? '已取消' : 'Cancelled')
} else {
setErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 110)
|| (isZh ? 'Auto-Trade 切换失败' : 'Auto-Trade toggle failed'))
}
} finally { setBusy(false) }
}
if (!mounted) {
return (
<div className="card" style={{ padding: 16, marginBottom: 16, fontSize: 13, color: 'var(--ink-3)' }}>
{isZh
? `连接右上角的钱包后,才能操作 ${s.name} 模块。`
: `Connect a wallet (top-right) to operate the ${s.name} system.`}
</div>
)
}
if (!isConnected) {
return (
<div className="card" style={{ padding: 16, marginBottom: 16, fontSize: 13, color: 'var(--ink-3)' }}>
{isZh
? `连接右上角的钱包后,才能操作 ${s.name} 模块。`
: `Connect a wallet (top-right) to operate the ${s.name} system.`}
</div>
)
}
// ── Net result — the single sentence that matters ─────────────────────────
let netOk = false
let netMsg = ''
if (!subscribed) {
netMsg = isZh
? '钱包还没订阅,请先去设置页完成订阅。'
: 'Wallet not subscribed — subscribe on the Settings page first.'
} else if (cbTripped) {
netMsg = isZh
? `熔断器已触发(${pub?.circuit_breaker_reason || '风险限制'})。重新打开 Auto-Trade 才会确认并恢复。`
: `Circuit breaker tripped (${pub?.circuit_breaker_reason || 'risk limit'}). Turn Auto-Trade ON to acknowledge & resume.`
} else if (!autoOn) {
netMsg = isZh
? 'Auto-Trade 当前为关闭(全局): Trump 和 BTC 信号仍会显示,但不会自动开仓。'
: 'Auto-Trade is OFF (global) — Trump AND BTC signals are shown in the feed but NOT traded.'
} else {
netOk = true
netMsg = isZh
? `Auto-Trade 已开启(全局,覆盖 Trump 和 BTC): 下一条合格信号会自动开出${paper ? '模拟' : '真实'}仓位。`
: `Auto-Trade ON (global, both Trump & BTC) — will open a ${paper ? 'PAPER' : 'LIVE'} trade on the next qualifying signal.`
}
const Pill = ({ active, onClick, children, tone }: {
active: boolean; onClick: () => void; children: React.ReactNode
tone: 'green' | 'idle'
}) => {
const bg = active ? (tone === 'green' ? 'var(--up, #16a34a)' : 'var(--ink-3)') : 'transparent'
return (
<button onClick={onClick} disabled={busy}
style={{
padding: '8px 20px', fontSize: 13, fontWeight: 800, borderRadius: 8,
cursor: busy ? 'wait' : 'pointer',
border: active ? `1px solid ${bg}` : '1px solid var(--line)',
background: active ? bg : 'transparent',
color: active ? '#fff' : 'var(--ink-3)',
}}>{children}</button>
)
}
return (
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '14px 16px', background: s.soft,
borderLeft: `4px solid ${s.accent}` }}>
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
{s.idx} {s.name} {isZh ? '控制面板' : '— control'}
</div>
</div>
{/* Master Auto-Trade switch */}
<div style={{ padding: '16px' }}>
<div style={ROW}>
<div>
<div style={{ fontSize: 14, fontWeight: 700 }}>
Auto-Trade <span style={{ fontSize: 10, fontWeight: 600,
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? '· 全局(Trump + BTC' : '· GLOBAL (Trump + BTC)'}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3, maxWidth: 480, lineHeight: 1.5 }}>
{isZh ? (
<>
<strong></strong>
Trump BTC
</>
) : (
<>
<strong>One switch for both systems.</strong> OFF: signals scanned
&amp; shown in the feed, nothing traded. ON: a qualifying Trump OR
BTC signal auto-opens a trade. Stop-loss / staged de-risk always
protect open positions either way.
</>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Pill active={autoOn} onClick={() => flipAuto(true)} tone="green">ON</Pill>
<Pill active={!autoOn} onClick={() => flipAuto(false)} tone="idle">OFF</Pill>
</div>
</div>
{/* Read-only status */}
<div style={{ ...ROW, borderTop: '1px solid var(--line)', marginTop: 8, paddingTop: 12 }}>
<span style={LABEL}>{isZh ? '执行模式' : 'Execution mode'}</span>
<span style={{ fontSize: 12, fontWeight: 700, color: paper ? '#f59e0b' : 'var(--up)' }}>
{paper ? (isZh ? '📝 模拟' : '📝 PAPER') : (isZh ? '💰 真实资金' : '💰 LIVE')}
<span style={{ color: 'var(--ink-4)', fontWeight: 400, marginLeft: 8 }}>
{isZh ? '· 订阅时确定' : '· set when subscribing'}
</span>
</span>
</div>
<div style={ROW}>
<span style={LABEL}>{isZh ? '熔断器' : 'Circuit breaker'}</span>
<span style={{ fontSize: 12, fontWeight: 700,
color: cbTripped ? 'var(--down)' : 'var(--up)' }}>
{cbTripped
? (isZh ? `🚨 已触发(${pub?.circuit_breaker_reason}` : `🚨 tripped (${pub?.circuit_breaker_reason})`)
: (isZh ? '✓ 正常' : '✓ clear')}
</span>
</div>
{err && (
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 10 }}> {err}</div>
)}
</div>
{/* Net result */}
<div style={{
padding: '14px 16px',
background: netOk ? 'var(--up-soft)' : 'var(--down-soft, rgba(220,38,38,.08))',
borderTop: '1px solid var(--line)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.06em',
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 4 }}>
{isZh ? '⟹ 当前结果' : '⟹ Net result'}
</div>
<div style={{ fontSize: 14, fontWeight: 700,
color: netOk ? 'var(--up)' : 'var(--down)' }}>
{netOk ? '✅ ' : '⛔ '}{netMsg}
</div>
</div>
</div>
)
}
+277
View File
@@ -0,0 +1,277 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import {
getTelegramStatus, tgInit, tgUnbind,
type TelegramStatus,
} from '@/lib/api'
import { signRequest } from '@/lib/signedRequest'
import { walletErrorLabel } from '@/lib/walletError'
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
/**
* Settings → Telegram alerts card.
*
* Three states:
* 1. Server not configured → show "ask the admin to set up the bot"
* 2. Not bound → deep-link to bot + optional wallet-link code
* 3. Bound → status line + link to bot for preferences
*
* Preferences are managed entirely inside the bot (/trump /btc /funding /kol
* /conf /quiet). This card is just discovery + wallet-linking for Pro users.
*/
export default function TelegramCard() {
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 [mounted, setMounted] = useState(false)
const [status, setStatus] = useState<TelegramStatus | null>(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [code, setCode] = useState<{ code: string; deep_link: string } | null>(null)
useEffect(() => { setMounted(true) }, [])
const refresh = useCallback(async () => {
if (!address) return
setLoading(true)
try {
const s = await getTelegramStatus(address.toLowerCase())
setStatus(s); setErr('')
} catch (e) {
setErr(e instanceof Error ? e.message : 'load failed')
} finally { setLoading(false) }
}, [address])
useEffect(() => { refresh() }, [refresh])
// Poll while waiting for wallet-link to complete.
useEffect(() => {
if (!code || status?.bound) return
const id = setInterval(refresh, 3000)
return () => clearInterval(id)
}, [code, status?.bound, refresh])
const botUsername = status?.bot_username ?? 'TrumpAlpha_bot'
const botLink = `https://t.me/${botUsername}`
async function handleLinkWallet() {
if (!address) return
setBusy(true); setErr('')
try {
const confirmed = await confirmSign({
label: 'Generate Telegram link code',
description: 'A one-time 6-char code valid for 10 min. Paste it into the bot to link your wallet for Pro features.',
})
if (!confirmed) { setBusy(false); return }
const env = await signRequest({
action: 'telegram_init', wallet: address, body: null, signMessageAsync,
})
const r = await tgInit(address.toLowerCase(), env)
setCode({ code: r.code, deep_link: r.deep_link })
} catch (e) {
setErr(walletErrorLabel(e, 'Cancelled'))
} finally { setBusy(false) }
}
async function handleUnbind() {
if (!address) return
setBusy(true); setErr('')
try {
const confirmed = await confirmSign({
label: 'Disconnect Telegram',
description: 'Unlinks this wallet from Telegram. Your free subscription stays active in the bot.',
})
if (!confirmed) { setBusy(false); return }
const env = await signRequest({
action: 'telegram_unbind', wallet: address, body: null, signMessageAsync,
})
await tgUnbind(address.toLowerCase(), env)
setCode(null)
await refresh()
} catch (e) {
setErr(walletErrorLabel(e, 'Cancelled'))
} finally { setBusy(false) }
}
// ── Render ───────────────────────────────────────────────────────────────
if (!mounted) return null
if (!isConnected) return null
return (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between',
alignItems: 'center', marginBottom: 12, gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-3)' }}>
{isZh ? 'Telegram 提醒' : 'Telegram alerts'}
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 2 }}>
{isZh ? '高置信度信号触发时发送推送提醒' : 'Push notifications when high-conviction signals fire'}
</div>
</div>
{status?.bound && status.alerts_enabled && (
<span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 600,
padding: '4px 10px', borderRadius: 6,
background: 'var(--up-soft)' }}>
{isZh ? '已开启' : 'Active'}
</span>
)}
{status?.bound && !status.alerts_enabled && (
<span style={{ fontSize: 11, color: 'var(--ink-4)', fontWeight: 600,
padding: '4px 10px', borderRadius: 6,
background: 'var(--bg-sunk)' }}>
{isZh ? '已暂停' : 'Paused'}
</span>
)}
</div>
{loading && (
<div className="skeleton sk-line sk-w-full" style={{ marginBottom: 8 }} />
)}
{/* Server not configured */}
{!loading && status && !status.configured && (
<div style={{ padding: 12, fontSize: 12, color: 'var(--ink-3)',
background: 'var(--bg-sunk)', borderRadius: 6 }}>
{isZh ? '当前服务器还没有配置 Telegram 提醒。请让管理员设置' : 'Telegram alerts are not configured on this server. Ask the operator to set'}
<code style={{ margin: '0 4px' }}>TELEGRAM_BOT_TOKEN</code> and
<code style={{ margin: '0 4px' }}>TELEGRAM_BOT_USERNAME</code>.
</div>
)}
{/* Configured — main body */}
{!loading && status?.configured && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Bot link row — always visible */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', borderRadius: 8,
background: 'var(--bg-sunk)', border: '1px solid var(--line)' }}>
<span style={{ fontSize: 18 }}></span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>
<a href={botLink} target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--amber, #f59e0b)', textDecoration: 'none' }}>
@{botUsername}
</a>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 2 }}>
{status.bound
? (isZh ? '打开机器人调整偏好(/trump /btc /funding /kol /conf /quiet' : 'Open the bot to adjust preferences (/trump /btc /funding /kol /conf /quiet)')
: (isZh ? '打开机器人并发送 /start,无需额外账号' : 'Open the bot and send /start — no account needed')}
</div>
</div>
</div>
{/* Bound: wallet status + disconnect */}
{status.bound && (
<div style={{ fontSize: 12, color: 'var(--ink-3)',
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>
{status.tg_username
? <>{isZh ? '已绑定账号' : 'Linked as'} <strong style={{ color: 'var(--ink)' }}>@{status.tg_username}</strong></>
: <>{isZh ? '聊天 ID' : 'Chat'} <strong style={{ color: 'var(--ink)' }}>#{status.chat_id}</strong></>}
</span>
{status.wallet_address ? (
<>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<span style={{ color: 'var(--up)', fontSize: 11 }}>Pro</span>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<button className="btn ghost" disabled={busy} onClick={handleUnbind}
style={{ fontSize: 11, color: 'var(--down)', padding: '2px 8px' }}>
{isZh ? '断开钱包绑定' : 'Disconnect wallet'}
</button>
</>
) : (
<>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
</>
)}
</div>
)}
{/* Not bound: optional wallet-link for Pro */}
{!status.bound && !code && (
<div style={{ fontSize: 12, color: 'var(--ink-4)' }}>
{isZh ? '已经在机器人里了?' : 'Already in the bot?'}{' '}
<button onClick={handleLinkWallet} disabled={busy}
style={{ background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--amber, #f59e0b)', fontSize: 12, padding: 0,
textDecoration: 'underline' }}>
{isZh ? '绑定这个钱包以启用 Pro 功能' : 'Link this wallet for Pro features'}
</button>
</div>
)}
{/* Wallet-link code flow */}
{!status.bound && code && (
<WalletLinkPanel code={code.code} link={code.deep_link}
onCancel={() => setCode(null)} isZh={isZh} />
)}
</div>
)}
{err && (
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}> {err}</div>
)}
</div>
)
}
// ── Subcomponents ────────────────────────────────────────────────────────────
function WalletLinkPanel({ code, link, onCancel, isZh }: {
code: string; link: string; onCancel: () => void; isZh: boolean
}) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1500)
}).catch(() => {})
}
return (
<div style={{ padding: 14, background: 'var(--bg-sunk)',
borderRadius: 8, border: '1px solid var(--line)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
{isZh ? '绑定钱包(Pro:' : 'Link wallet (Pro):'}
</div>
<ol style={{ paddingLeft: 18, fontSize: 12, color: 'var(--ink-2)',
lineHeight: 1.7, marginBottom: 12 }}>
<li>
{isZh ? '打开机器人:' : 'Open the bot:'}&nbsp;
<a href={link} target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--amber, #f59e0b)', fontWeight: 600 }}>
{link.replace('https://', '')}
</a>
</li>
<li>
{isZh ? '发送' : 'Send'} <code style={{ background: 'var(--bg)', padding: '1px 6px',
borderRadius: 3 }}>/start {code}</code>
<button onClick={copy} style={{
marginLeft: 6, fontSize: 11, padding: '2px 8px', borderRadius: 4,
border: '1px solid var(--line)', background: 'var(--bg)',
cursor: 'pointer', color: 'var(--ink-3)',
}}>{copied ? (isZh ? '✓ 已复制' : '✓ copied') : (isZh ? '复制验证码' : 'copy code')}</button>
</li>
</ol>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginBottom: 10 }}>
{isZh ? '验证码 10 分钟内有效,绑定成功后这里会自动刷新。' : 'Code expires in 10 min. This panel updates automatically once linked.'}
</div>
<button onClick={onCancel} style={{
fontSize: 11, color: 'var(--ink-3)', background: 'none',
border: 'none', cursor: 'pointer', padding: 0,
}}>{isZh ? '取消' : 'Cancel'}</button>
</div>
)
}
+942
View File
@@ -0,0 +1,942 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useConnect, useSignMessage } from 'wagmi'
import {
getUserPublic,
getUser,
setUserSettings,
setHlApiKey,
setManualWindow,
subscribe,
type UserSettings,
} from '@/lib/api'
import { useDashboardStore } from '@/store/dashboard'
import {
signRequest,
getOrCreateViewEnvelope,
getCachedViewEnvelope,
} from '@/lib/signedRequest'
import { walletErrorLabel } from '@/lib/walletError'
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
// 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, sys2_leverage: 2,
sys2_mode: 'standard',
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 ─────────────────────────────────────────────────────────
export default function BotConfigPanel() {
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 { 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.
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. `subPaperChoice` is the user's selection on the
// pre-subscribe form ("paper" or "live"); written to Subscription.paper_mode
// when the signed subscribe goes through.
const [subState, setSubState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
const [subErr, setSubErr] = useState('')
const [subPaperChoice, setSubPaperChoice] = useState<'paper' | 'live'>('paper')
// Manual window (convex-strategy "enable for N hours" override).
// null = no override active; ISO string = bot armed until that UTC time.
const [manualUntil, setManualUntil] = useState<string | null>(null)
const [mwState, setMwState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
const [mwErr, setMwErr] = useState('')
const [mwTick, setMwTick] = useState(0) // forces countdown re-render
// Mounted flag: avoid SSR/CSR mismatch since wagmi state differs between the two.
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
// Settings-load state. 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
manual_window_until?: string | null
}) {
setSubscribed(u.active)
setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined)
setManualUntil(u.manual_window_until ?? null)
if (u.settings) {
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
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]) // eslint-disable-line react-hooks/exhaustive-deps
async function handleLoadSettings() {
if (!address) return
setLoadErr(''); setLoadState('loading')
try {
const ok = await confirmSign({
label: isZh ? '查看账户设置' : 'View account settings',
description: isZh
? '读取你在 Trump Alpha 的订阅状态、风控参数和 API Key 状态。签名仅用于身份验证,不做任何链上操作。'
: 'Read your Trump Alpha subscription state, risk settings, and API key status. The signature is only for identity verification. No on-chain action is performed.',
})
if (!ok) { setLoadState('idle'); return }
const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync })
const u = await getUser(address, env)
applyUserPayload(u)
setLoadState('loaded')
} catch (e: unknown) {
setLoadErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 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
const paperMode = subPaperChoice === 'paper'
const ok = await confirmSign({
label: paperMode
? (isZh ? '订阅 Trump Alpha(模拟模式)' : 'Subscribe to Trump Alpha (paper mode)')
: (isZh ? '订阅 Trump Alpha(真实资金)' : 'Subscribe to Trump Alpha (live capital)'),
description: paperMode
? (isZh
? '注册为 Paper 模式订阅者。机器人开仓不涉及真实资金,仅供测试。'
: 'Register as a paper-mode subscriber. Trades are simulated and do not touch real funds.')
: (isZh
? '注册为正式订阅者。完成后需配置 Hyperliquid API Key 才能实际开仓。'
: 'Register as a live subscriber. You still need to configure a Hyperliquid API key before any real trade can open.'),
danger: !paperMode,
})
if (!ok) return
setSubErr(''); setSubState('signing')
try {
const signedBody = paperMode ? { paper_mode: true } : null
const env = await signRequest({ action: 'subscribe', wallet: address, body: signedBody, signMessageAsync })
setSubState('saving')
await subscribe(env, { paper_mode: paperMode })
setSubscribed(true)
void refreshUserState(address)
setSubState('idle')
setTpConfigured(false)
setSlConfigured(false)
setBudgetConfigured(false)
setLoadState('loaded')
} catch (e: unknown) {
setSubErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 100))
setSubState('err')
}
}
async function handleSaveSettings() {
if (!address) return
const ok = await confirmSign({
label: isZh ? '保存交易风控参数' : 'Save trading risk settings',
description: isZh
? '保存杠杆、仓位大小、止盈止损比例和每日预算设置。签名后立即生效。'
: 'Save leverage, position size, take-profit, stop-loss, and daily budget settings. Changes apply immediately after signing.',
})
if (!ok) 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(isZh ? '请选择开始和结束时间' : 'Pick both a start and end time'); setSaveState('err'); return
}
if (new Date(schedUntil).getTime() <= new Date(schedFrom).getTime()) {
setSaveErr(isZh ? '结束时间必须晚于开始时间' : 'End must be after start'); setSaveState('err'); return
}
}
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(isZh ? '止盈必须在 0.1% 到 50% 之间' : 'Take profit must be 0.1 50%'); setSaveState('err'); return }
if (sl == null || sl < 0.1 || sl > 50) { setSaveErr(isZh ? '止损必须在 0.1% 到 50% 之间' : 'Stop loss must be 0.1 50%'); setSaveState('err'); return }
if (bd == null || bd <= 0 || bd > 100000){ setSaveErr(isZh ? '每日预算必须大于 0 美元' : '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) {
setSaveErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 100))
setSaveState('err')
}
}
async function handleSaveKey() {
if (!address || !apiKey.trim()) return
const trimmed = apiKey.trim()
if (!trimmed.startsWith('0x') || trimmed.length !== 66) {
setKeyErr(isZh ? '必须是 0x 开头加 64 位十六进制字符' : 'Must be 0x + 64 hex chars'); setKeyState('err'); return
}
const ok = await confirmSign({
label: isZh ? '绑定 Hyperliquid API Key' : 'Link Hyperliquid API key',
description: isZh
? 'API Key 将加密存储在服务端,用于机器人在 Hyperliquid 上代替你下单。签名用于验证是你本人操作。'
: 'The API key is stored encrypted on the server and used by the bot to place trades on Hyperliquid for you. The signature confirms this action came from you.',
danger: true,
})
if (!ok) 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) {
setKeyErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 100))
setKeyState('err')
}
}
const handleConnectWallet = useCallback(() => {
const connector = connectors[0]
if (connector) connect({ connector })
}, [connect, connectors])
// ── Manual-window: arm for N hours, or clear (hours = 0) ──────────────────
async function handleManualWindow(hours: number) {
if (!address) return
setMwErr(''); setMwState('signing')
try {
const env = await signRequest({
action: 'set_manual_window',
wallet: address,
body: { hours },
signMessageAsync,
})
setMwState('saving')
const r = await setManualWindow(env, hours)
setManualUntil(r.manual_window_until)
setMwState('idle')
} catch (e: unknown) {
setMwErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 100))
setMwState('err')
}
}
// Countdown re-render every 15s while a manual window is armed.
useEffect(() => {
if (!manualUntil) return
const id = setInterval(() => setMwTick(t => t + 1), 15000)
return () => clearInterval(id)
}, [manualUntil])
if (!mounted || !isConnected) {
return (
<div className="card" style={{ padding: 32, textAlign: 'center', marginBottom: 28 }}>
<p style={{ color: 'var(--ink-3)', marginBottom: 16 }}>
{isZh ? '连接钱包后即可配置机器人并查看你的交易。' : 'Connect your wallet to configure the bot and view your trades.'}
</p>
<button className="btn amber" onClick={handleConnectWallet}>{isZh ? '连接钱包' : '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' ? (isZh ? '钱包签名中…' : 'Sign in wallet…')
: saveState === 'saving' ? (isZh ? '保存中…' : 'Saving…')
: saveState === 'ok' ? (isZh ? '✓ 已保存' : '✓ Saved')
: (isZh ? '保存更改' : 'Save changes')
const keyHint =
hlApiKeySet && !apiKey
? (isZh
? '已保存在你的机器人配置里。如果你在 Hyperliquid 轮换了 API 钱包,重新交易前请先在这里更新。'
: 'Saved in your bot profile. If you rotate the API wallet in Hyperliquid, update it here before trading again.')
: (isZh
? '请使用 app.hyperliquid.xyz/API 生成的 Hyperliquid API 钱包私钥。不要在这里粘贴 MetaMask 助记词或私钥。'
: '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'
? (isZh
? 'API 钱包已保存。建议下一步先用极小仓位跑一次你自己的 BTC 测试,再把这套配置当成生产可用。'
: '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 ─────────────────────────────────────────────────
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
? <><span>Linked · </span><span className="mono">{hlApiKeyMasked ?? '···'}</span><span> · trade-only scope</span></>
: '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>
)
// ── Setup readiness ───────────────────────────────────────────────────────
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 }}>
{/* ── Setup progress banner ── */}
<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}
{/* ── Manual window (convex-strategy override) ── */}
{isSubscribed && (() => {
const untilMs = manualUntil ? new Date(manualUntil).getTime() : 0
// Touch the tick counter so countdown re-renders even if `manualUntil` is unchanged.
void mwTick
const remainingMin = untilMs ? Math.max(0, Math.round((untilMs - Date.now()) / 60000)) : 0
const armed = remainingMin > 0
const remainingLabel =
remainingMin >= 60 ? `${Math.floor(remainingMin / 60)}h ${remainingMin % 60}m`
: `${remainingMin}m`
const busy = mwState === 'signing' || mwState === 'saving'
return (
<div
className="card"
style={{
padding: '14px 18px',
marginBottom: 16,
background: armed ? 'var(--up-soft)' : undefined,
borderColor: armed ? 'color-mix(in oklab, var(--up) 22%, var(--line))' : undefined,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{armed ? '● Manually armed' : '○ Manual window'}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
{armed
? <>Bot is live for <strong>{remainingLabel}</strong> more overrides any schedule.</>
: 'Arm the bot for a specific window. Best for catalysts (CPI, FOMC, news drops).'}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{!armed && (
<>
{[1, 4, 24].map(h => (
<button
key={h}
className="btn ghost"
style={{ padding: '6px 12px', fontSize: 12 }}
onClick={() => handleManualWindow(h)}
disabled={busy}
>
{busy && mwState === 'signing' ? 'Sign…' : `${h}h`}
</button>
))}
</>
)}
{armed && (
<button
className="btn ghost"
style={{ padding: '6px 14px', fontSize: 12 }}
onClick={() => handleManualWindow(0)}
disabled={busy}
>
{busy ? 'Sign…' : 'Disarm'}
</button>
)}
</div>
</div>
{mwState === 'err' && (
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}>{mwErr}</div>
)}
</div>
)
})()}
{/* ── Main settings card ── */}
<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 ? (
// Subscribe button moved into the body to live alongside the
// paper-vs-live choice. Header keeps only status chips.
<span className="chip" style={{ fontSize: 11, background: 'var(--bg-sunk)' }}>
Not subscribed
</span>
) : (
<>
<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 */}
{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>
)}
{/* Setup-complete banner */}
{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>
)}
{/* Body */}
{!isSubscribed ? (
<div style={{ padding: 28 }}>
<div style={{ color: 'var(--ink-3)', fontSize: 13, lineHeight: 1.65, marginBottom: 18 }}>
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.
</div>
{/* ── Paper-vs-Live choice (P0.2) ────────────────────────────
New users land on PAPER by default. Forces a conscious
opt-in to live trading rather than auto-routing real money. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 16 }}>
{([
{
key: 'paper',
title: '📝 Start in paper mode',
tag: 'Recommended',
body: 'Trades are simulated end-to-end. No funds at risk, no HL key needed. Promote to live anytime.',
},
{
key: 'live',
title: '💰 Start live',
tag: 'Real money',
body: 'Bot trades real positions on Hyperliquid. Requires an HL API key. Risk = your full position size.',
},
] as const).map(opt => {
const active = subPaperChoice === opt.key
return (
<button
key={opt.key}
onClick={() => setSubPaperChoice(opt.key)}
style={{
textAlign: 'left', cursor: 'pointer',
padding: '14px 16px', borderRadius: 10,
background: active ? 'var(--up-soft)' : 'var(--bg-sunk)',
border: active
? '1px solid color-mix(in oklab, var(--up) 30%, var(--line))'
: '1px solid var(--line)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{opt.title}</span>
<span style={{
fontSize: 10, padding: '2px 8px', borderRadius: 4,
background: opt.key === 'paper' ? 'var(--up-soft)' : 'var(--amber-soft)',
color: opt.key === 'paper' ? 'var(--up)' : 'var(--amber-ink)',
fontWeight: 600,
}}>{opt.tag}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', lineHeight: 1.5 }}>
{opt.body}
</div>
</button>
)
})}
</div>
{/* The subscribe button moves down here so paper choice is
resolved before the wallet popup. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
className="btn amber"
style={{ padding: '10px 22px', fontSize: 13 }}
onClick={handleSubscribe}
disabled={subState === 'signing' || subState === 'saving'}
>
{subState === 'signing'
? 'Sign in wallet…'
: subState === 'saving'
? 'Activating…'
: subPaperChoice === 'paper'
? 'Subscribe in paper mode'
: 'Subscribe (live trading)'}
</button>
{subState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{subErr}</span>}
</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&apos;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 style={{ opacity: 0.6 }}>(Trump / System 1)</span>
<span className="hint">Event-driven scalp positions</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>
{(() => {
const aggressive = settings.sys2_mode === 'aggressive'
return (
<div className="form-row">
<div className="form-row-label">
BTC strategy mode <span style={{ opacity: 0.6 }}>(System 2)</span>
<span className="hint">
A separately-funded sleeve. <strong>Aggressive</strong> =
high leverage + earlier/heavier pyramiding + wider
trailing explosive in a clean bull, but a normal
2535% correction will scale you out hard. Both modes
stay inside the liquidation line.
</span>
</div>
<div className="form-row-control">
<div style={{ display: 'flex', gap: 6 }}>
<button type="button"
onClick={() => updateSettings({ sys2_mode: 'standard' })}
className={`btn ${!aggressive ? '' : 'ghost'}`}
style={{ padding: '7px 14px', fontSize: 12, fontWeight: 700 }}>
Standard
</button>
<button type="button"
onClick={() => updateSettings({ sys2_mode: 'aggressive' })}
className={`btn ${aggressive ? '' : 'ghost'}`}
style={{ padding: '7px 14px', fontSize: 12, fontWeight: 700,
...(aggressive ? { background: 'var(--down)', color: '#fff', border: 'none' } : {}) }}>
🔥 Aggressive
</button>
</div>
{aggressive && (
<div className="hint" style={{ marginTop: 6, color: 'var(--down)' }}>
High-risk sleeve: default 8×, pyramids up to +1.5×
base, gives back up to 42% off the peak. Fund this
separately expect frequent full scale-outs on
corrections in exchange for explosive clean runs.
</div>
)}
</div>
</div>
)
})()}
{(() => {
const aggressive = settings.sys2_mode === 'aggressive'
const defLev = aggressive ? 8 : 2
const lev = Math.max(1, Math.min(10, settings.sys2_leverage ?? defLev))
const prot = Math.min(35, 0.85 * 100 / lev)
const liq = 100 / lev
const risky = lev > 2
const e1 = aggressive ? '¼' : '⅓'
const e2 = aggressive ? '¼' : '⅓'
return (
<div className="form-row">
<div className="form-row-label">
BTC bottom leverage <span style={{ opacity: 0.6 }}>(System 2)</span>
<span className="hint">
Independent of Trump. Wrong scales OUT in 3 stages
inside the liquidation line (<strong>never
exchange-liquidated</strong>). Right pyramids IN on a
confirmed trend, stop floored at breakeven.
</span>
</div>
<div className="form-row-control">
<div className="slider-field">
<input type="range" min={1} max={10} value={lev}
onChange={e => updateSettings({ sys2_leverage: +e.target.value })} />
<div className="ticks"><span>1×</span><span>5×</span><span>10×</span></div>
</div>
<span className="slider-readout">{lev}×</span>
<div className="hint" style={{ marginTop: 6, color: risky ? 'var(--down)' : 'var(--ink-3)' }}>
At {lev}× it sheds {e1} near {(prot * 0.6).toFixed(0)}%,
{' '}{e2} near {(prot * 0.8).toFixed(0)}%, fully out by
{prot.toFixed(0)}% (exchange would liquidate
{liq.toFixed(0)}%).{' '}
{risky
? (aggressive
? '🔥 Aggressive: a normal 2535% bull correction will scale you out — youre betting on clean explosive runs.'
: '⚠️ Above 2× a normal 2535% bull correction can scale you out before the top. To ride a super-bull, keep ≤2× — amplify via pyramiding, not leverage.')
: 'Wide enough to ride normal bull corrections; amplification comes from pyramiding.'}
</div>
</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&apos;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&apos;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 durH = (untilMs - fromMs) / 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>
)
}
+331
View File
@@ -0,0 +1,331 @@
'use client'
import { useState, useMemo } from 'react'
import { useLocale } from 'next-intl'
import type { BotTrade, TrumpPost } from '@/types'
// ── Formatters ────────────────────────────────────────────────────────────────
function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
const { decimals = 2, sign = false } = opts
if (n == null || isNaN(n)) return '—'
const abs = Math.abs(n)
const s = abs.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
if (n < 0) return '-$' + s
if (sign && n > 0) return '+$' + s
return '$' + s
}
function fmtPct(n: number) { return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' }
function fmtHold(s: number) {
if (s < 60) return s + 's'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm'
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
}
interface Props {
trades: BotTrade[]
posts: TrumpPost[]
loading: boolean
}
const ASSETS = ['all', 'BTC', 'ETH', 'SOL'] as const
const SIDES = ['all', 'long', 'short'] as const
export default function TradeTable({ trades, posts, loading }: Props) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const [assetFilter, setAssetFilter] = useState('all')
const [sideFilter, setSideFilter] = useState('all')
const [sourceFilter, setSourceFilter] = useState('all')
const [hidePaper, setHidePaper] = useState(false)
// Distinct sources present in the loaded trade set — drives the filter UI.
// Includes 'unknown' as a bucket for trades whose trigger post was deleted.
const sources = useMemo(() => {
const set = new Set<string>()
for (const t of trades) set.add(t.trigger_source || 'unknown')
return Array.from(set).sort()
}, [trades])
// Per-source PnL aggregate — the critical view for "which module makes money".
// Computed BEFORE the asset/side filter so the source breakdown reflects the
// full universe, not whatever sub-filter is currently applied.
const perSource = useMemo(() => {
const acc: Record<string, { trades: number; pnl: number; wins: number; paper: number }> = {}
for (const t of trades) {
const k = t.trigger_source || 'unknown'
if (!acc[k]) acc[k] = { trades: 0, pnl: 0, wins: 0, paper: 0 }
acc[k].trades += 1
if (t.pnl_usd != null) {
acc[k].pnl += t.pnl_usd
if (t.pnl_usd > 0) acc[k].wins += 1
}
if (t.is_paper) acc[k].paper += 1
}
return acc
}, [trades])
const filtered = trades.filter(t => {
if (sourceFilter !== 'all' && (t.trigger_source || 'unknown') !== sourceFilter) return false
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
if (sideFilter !== 'all' && t.side !== sideFilter) return false
if (hidePaper && t.is_paper) return false
return true
})
// Exclude externally-closed trades (pnl_usd null) from aggregates.
const priced = filtered.filter(t => t.pnl_usd !== null && t.pnl_usd !== undefined)
const totalPnl = priced.reduce((s, t) => s + (t.pnl_usd ?? 0), 0)
const wins = priced.filter(t => (t.pnl_usd ?? 0) > 0).length
const losses = priced.length - wins
const avgHold = filtered.length > 0
? Math.round(filtered.reduce((s, t) => s + t.hold_seconds, 0) / filtered.length)
: 0
return (
<>
{/* ── Per-source breakdown (the "which module makes money" view) ── */}
{sources.length > 1 && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{
fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 10,
}}>
{isZh ? '按信号来源拆分盈亏' : 'P&L by signal source'}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: 10,
}}>
{sources.map(src => {
const s = perSource[src]
const winRate = s.trades ? (s.wins / s.trades) * 100 : 0
const tone = s.pnl > 0 ? 'up' : s.pnl < 0 ? 'down' : 'idle'
const bg = tone === 'up' ? 'var(--up-soft)'
: tone === 'down' ? 'var(--down-soft)'
: 'var(--bg-sunk)'
const fg = tone === 'up' ? 'var(--up)'
: tone === 'down' ? 'var(--down)'
: 'var(--ink-2)'
return (
<button
key={src}
onClick={() => setSourceFilter(sourceFilter === src ? 'all' : src)}
style={{
textAlign: 'left', cursor: 'pointer',
background: bg, borderRadius: 8, padding: '12px 14px',
border: `1px solid ${sourceFilter === src ? fg : 'transparent'}`,
}}
>
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{src}
{s.paper > 0 && (
<span style={{
fontSize: 9, marginLeft: 6, padding: '1px 5px',
borderRadius: 3, background: 'rgba(245,158,11,0.15)',
color: '#f59e0b',
}}>
{s.paper}P
</span>
)}
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: fg, fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(s.pnl, { sign: true, decimals: 0 })}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 2 }}>
{isZh ? `${s.trades} 笔交易 · ${winRate.toFixed(0)}% 胜率` : `${s.trades} trades · ${winRate.toFixed(0)}% win`}
</div>
</button>
)
})}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-4)', marginTop: 8 }}>
{isZh ? '点击来源可筛选表格。' : 'Click a source to filter the table.'} {sourceFilter !== 'all' && (
<button
onClick={() => setSourceFilter('all')}
style={{ marginLeft: 8, padding: '2px 8px', fontSize: 10,
border: '1px solid var(--line)', borderRadius: 4,
background: 'transparent', cursor: 'pointer', color: 'var(--ink-2)' }}
>
{isZh ? `清除(当前为 “${sourceFilter}”)` : `Clear (showing “${sourceFilter}”)`}
</button>
)}
</div>
</div>
)}
{/* KPI row */}
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4,1fr)', marginBottom: 20 }}>
<div className="kpi">
<div className="label">{isZh ? '总交易数' : 'Total trades'}</div>
<div className="value">{filtered.length}</div>
</div>
<div className="kpi">
<div className="label">{isZh ? '胜率' : 'Win rate'}</div>
<div className="value">{priced.length ? ((wins / priced.length) * 100).toFixed(1) + '%' : '—'}</div>
<div className="foot"><span>{isZh ? `${wins} 赢 · ${losses}` : `${wins}W · ${losses}L`}</span></div>
</div>
<div className="kpi accent">
<div className="label">{isZh ? '净盈亏' : 'Net P&L'}</div>
<div className="value">{fmtMoney(totalPnl, { sign: true, decimals: 0 })}</div>
</div>
<div className="kpi">
<div className="label">{isZh ? '平均持仓' : 'Avg hold'}</div>
<div className="value">{avgHold ? fmtHold(avgHold) : '—'}</div>
</div>
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginBottom: 14 }}>
<div className="nav-tabs">
{ASSETS.map(a => (
<button
key={a}
className={`nav-tab ${assetFilter === a ? 'active' : ''}`}
onClick={() => setAssetFilter(a)}
>
{a === 'all' ? (isZh ? '全部资产' : 'All assets') : a}
</button>
))}
</div>
<div className="nav-tabs">
{SIDES.map(s => (
<button
key={s}
className={`nav-tab ${sideFilter === s ? 'active' : ''}`}
onClick={() => setSideFilter(s)}
>
{s === 'all' ? (isZh ? '全部方向' : 'All') : s === 'long' ? (isZh ? '做多' : 'Long') : (isZh ? '做空' : 'Short')}
</button>
))}
</div>
{/* Hide-paper toggle: paper trades inflate volume but aren't real $ — */}
{/* let users blend them out before reading the KPI row. */}
<label style={{
display: 'flex', alignItems: 'center', gap: 6, fontSize: 12,
color: 'var(--ink-3)', cursor: 'pointer', marginLeft: 'auto',
}}>
<input
type="checkbox"
checked={hidePaper}
onChange={e => setHidePaper(e.target.checked)}
/>
{isZh ? '隐藏模拟交易' : 'Hide paper trades'}
</label>
</div>
{/* Loading state */}
{loading && (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>{isZh ? '加载中…' : 'Loading…'}</div>
)}
{/* Table */}
{!loading && (
<div className="card flush" style={{ overflow: 'hidden' }}>
<table className="table">
<thead>
<tr>
<th>{isZh ? '来源' : 'Source'}</th>
<th>{isZh ? '资产' : 'Asset'}</th>
<th>{isZh ? '方向' : 'Side'}</th>
<th>{isZh ? '开仓' : 'Entry'}</th>
<th>{isZh ? '平仓' : 'Exit'}</th>
<th>{isZh ? '持仓' : 'Hold'}</th>
<th>{isZh ? '触发内容' : 'Trigger'}</th>
<th>P&amp;L</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<td colSpan={8} style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}>
{isZh ? '没有符合条件的交易。' : 'No trades found'}
</td>
</tr>
)}
{filtered.map(t => {
const tp = posts.find(p => p.id === t.trigger_post_id)
const roi =
t.entry_price && t.exit_price != null
? ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1)
: 0
const src = t.trigger_source || 'unknown'
return (
<tr key={t.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
padding: '2px 7px', borderRadius: 4,
background: 'var(--bg-sunk)',
}}>
{src}
</span>
{t.is_paper && (
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 3,
background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
}}>
PAPER
</span>
)}
</div>
</td>
<td>
<div className="row gap-s">
<span className={`asset-dot ${t.asset.toLowerCase()}`} />
<span style={{ fontWeight: 500 }}>{t.asset}</span>
</div>
</td>
<td>
<span className={`side-pill ${t.side}`}>
{t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')}
</span>
</td>
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 260 }}>
{tp ? (
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{tp.text.slice(0, 60)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
)}
</td>
<td>
<div className="stack" style={{ alignItems: 'flex-end' }}>
{t.pnl_usd === null || t.pnl_usd === undefined ? (
<span
style={{ fontSize: 12, color: 'var(--ink-4)' }}
title={isZh ? '在 Hyperliquid 外部平仓,PnL 未记录' : 'Closed externally on Hyperliquid — PnL not recorded'}
>
n/a
</span>
) : (
<>
<span className={`delta ${t.pnl_usd >= 0 ? 'up' : 'down'}`} style={{ fontWeight: 600, fontSize: 14 }}>
{fmtMoney(t.pnl_usd, { sign: true })}
</span>
<span className={`delta ${roi >= 0 ? 'up' : 'down'}`} style={{ fontSize: 11, opacity: 0.7 }}>
{fmtPct(roi)}
</span>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)
}
+182
View File
@@ -0,0 +1,182 @@
'use client'
/**
* SignConfirmSheet — elegant pre-signing confirmation UI.
*
* Usage (imperative, no context needed):
*
* import { confirmSign } from '@/components/wallet/SignConfirmSheet'
*
* const ok = await confirmSign({
* label: 'Enable Auto-Trade',
* description: 'Bot will open live trades on Hyperliquid when signals fire.',
* danger: true,
* })
* if (!ok) return // user cancelled
* const env = await signRequest(...) // only NOW triggers MetaMask
*/
import { createRoot } from 'react-dom/client'
export interface SignConfirmOptions {
/** Short action title, e.g. "Enable Auto-Trade" */
label: string
/** One-sentence explanation shown to the user */
description: string
/** If true, renders a red/warning accent — for irreversible / live-money actions */
danger?: boolean
}
/** Renders a confirmation sheet and resolves true (confirm) or false (cancel). */
export function confirmSign(opts: SignConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
function cleanup(result: boolean) {
root.unmount()
container.remove()
resolve(result)
}
root.render(<Sheet {...opts} onConfirm={() => cleanup(true)} onCancel={() => cleanup(false)} />)
})
}
/* ─── Internal sheet component ─────────────────────────────────────────────── */
function Sheet({
label, description, danger,
onConfirm, onCancel,
}: SignConfirmOptions & { onConfirm: () => void; onCancel: () => void }) {
const accent = danger ? '#ef4444' : '#f5a524'
const accentSoft = danger ? 'rgba(239,68,68,0.1)' : 'rgba(245,165,36,0.1)'
// Dismiss on backdrop click
function handleBackdrop(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onCancel()
}
// Dismiss on Escape
function handleKey(e: React.KeyboardEvent) {
if (e.key === 'Escape') onCancel()
}
return (
<div
onClick={handleBackdrop}
onKeyDown={handleKey}
role="dialog"
aria-modal="true"
aria-label={label}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.70)',
backdropFilter: 'blur(3px)',
WebkitBackdropFilter: 'blur(3px)',
zIndex: 99999,
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
padding: '0 0 env(safe-area-inset-bottom, 0)',
}}
>
<div
style={{
width: '100%', maxWidth: 480,
background: '#111',
borderRadius: '16px 16px 0 0',
border: '1px solid #2a2a2a',
borderBottom: 'none',
padding: '28px 24px 32px',
boxShadow: '0 -12px 48px rgba(0,0,0,0.5)',
animation: 'signSheetIn 0.22s cubic-bezier(0.32,0.72,0,1)',
}}
>
{/* 拖拽条 */}
<div style={{
width: 36, height: 4, borderRadius: 2,
background: '#333', margin: '0 auto 24px',
}} />
{/* 图标 + 标题 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: accentSoft,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, flexShrink: 0,
}}>
🔏
</div>
<div>
<div style={{ fontSize: 11, color: '#666', letterSpacing: '0.07em',
textTransform: 'uppercase', marginBottom: 2 }}>
Wallet signature required
</div>
<div style={{ fontSize: 17, fontWeight: 700, color: '#fff', lineHeight: 1.2 }}>
{label}
</div>
</div>
</div>
{/* 说明文字 */}
<div style={{
fontSize: 14, color: '#999', lineHeight: 1.6,
margin: '16px 0 24px',
paddingLeft: 52, // 与标题对齐
}}>
{description}
</div>
{/* 说明条 */}
<div style={{
padding: '10px 12px', borderRadius: 8,
background: 'rgba(255,255,255,0.04)',
border: '1px solid #2a2a2a',
fontSize: 12, color: '#666', lineHeight: 1.5,
marginBottom: 20,
}}>
Signing is used only to verify your identity. It will NOT authorize
any on-chain transfer and will NOT cost any gas. The MetaMask popup
will appear after you click Confirm.
</div>
{/* 按钮组 */}
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={onCancel}
style={{
flex: 1, padding: '12px 0',
borderRadius: 10, border: '1px solid #2a2a2a',
background: 'transparent', color: '#888',
fontSize: 14, fontWeight: 600, cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={onConfirm}
autoFocus
style={{
flex: 2, padding: '12px 0',
borderRadius: 10, border: 'none',
background: accent, color: danger ? '#fff' : '#000',
fontSize: 14, fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}
>
<span>Open MetaMask</span>
<span style={{ opacity: 0.7, fontSize: 16 }}></span>
</button>
</div>
</div>
<style>{`
@keyframes signSheetIn {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
import nextVitals from 'eslint-config-next/core-web-vitals'
const config = [
...nextVitals,
{
rules: {
'react/no-unescaped-entities': 'off',
'react-hooks/purity': 'off',
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/static-components': 'off',
},
},
]
export default config
+5 -5
View File
@@ -1,14 +1,14 @@
import { getRequestConfig } from 'next-intl/server' import { getRequestConfig } from 'next-intl/server'
// English-only. Other locales were listed historically but have no message export const locales = ['en', 'zh'] as const
// files, which caused browsers with non-English Accept-Language to be routed
// to a missing bundle. Keep in sync with messages/*.json.
export const locales = ['en'] as const
export type Locale = (typeof locales)[number] export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'en' export const defaultLocale: Locale = 'en'
export default getRequestConfig(async ({ requestLocale }) => { export default getRequestConfig(async ({ requestLocale }) => {
const locale = (await requestLocale) ?? defaultLocale const requested = (await requestLocale) ?? defaultLocale
const locale = locales.includes(requested as Locale)
? requested
: defaultLocale
return { return {
locale, locale,
messages: (await import(`./messages/${locale}.json`)).default, messages: (await import(`./messages/${locale}.json`)).default,
+420 -9
View File
@@ -1,10 +1,20 @@
import type { TrumpPost, Candle, BotTrade, BotPerformance } from '@/types' import type {
TrumpPost, Candle, BotTrade, BotPerformance,
KolPostSummary, KolPostDetail, KolDigest,
KolWallet, KolHoldingChange, KolDivergence,
} from '@/types'
import type { SignedEnvelope } from './signedRequest' import type { SignedEnvelope } from './signedRequest'
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
function getApiOrigin() {
return typeof window === 'undefined'
? `${BASE_URL}/api`
: '/api/proxy/api'
}
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> { async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}/api${path}`, { const res = await fetch(`${getApiOrigin()}${path}`, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
...init, ...init,
}) })
@@ -31,18 +41,42 @@ export async function getPrices(asset: 'BTC' | 'ETH', tf: string): Promise<Candl
return fetchJson<Candle[]>(`/prices/${asset}?tf=${tf}`) return fetchJson<Candle[]>(`/prices/${asset}?tf=${tf}`)
} }
export async function getTrades(limit = 20, page = 1): Promise<BotTrade[]> { export async function getTrades(
return fetchJson<BotTrade[]>(`/trades?limit=${limit}&page=${page}`) wallet: string,
env: SignedEnvelope,
limit = 20,
page = 1,
): Promise<BotTrade[]> {
const qs = [
`wallet=${wallet.toLowerCase()}`,
`ts=${env.timestamp}`,
`sig=${encodeURIComponent(env.signature)}`,
`limit=${limit}`,
`page=${page}`,
].join('&')
return fetchJson<BotTrade[]>(`/trades?${qs}`)
} }
export async function getPerformance(): Promise<BotPerformance> { export async function getPerformance(
return fetchJson<BotPerformance>(`/performance`) wallet: string,
env: SignedEnvelope,
): Promise<BotPerformance> {
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
return fetchJson<BotPerformance>(`/performance?${qs}`)
} }
export async function subscribe(env: SignedEnvelope): Promise<{ status: string; wallet: string }> { export async function subscribe(
return fetchJson<{ status: string; wallet: string }>(`/subscribe`, { env: SignedEnvelope,
options: { paper_mode?: boolean } = {},
): Promise<{ status: string; wallet: string; paper_mode: boolean }> {
// paper_mode is a top-level body field (not nested) so the canonical
// signed body matches the backend's expectation: { paper_mode: true } | null.
const payload = options.paper_mode
? { ...env, paper_mode: true }
: env
return fetchJson<{ status: string; wallet: string; paper_mode: boolean }>(`/subscribe`, {
method: 'POST', method: 'POST',
body: JSON.stringify(env), body: JSON.stringify(payload),
}) })
} }
@@ -53,6 +87,13 @@ export interface UserSettings {
stop_loss_pct: number | null stop_loss_pct: number | null
min_confidence: number min_confidence: number
daily_budget_usd: number | null daily_budget_usd: number | null
/** System-2 (bottom reversal) leverage, 110. null = platform default.
* Independent of `leverage` (Trump). The protective stop auto-scales to
* this so the position is never exchange-liquidated. */
sys2_leverage: number | null
/** System-2 risk mode: 'standard' (cycle-rider) or 'aggressive'
* (separately-funded high-risk/high-explosiveness sleeve). */
sys2_mode?: 'standard' | 'aggressive' | null
active_from: string | null // ISO UTC active_from: string | null // ISO UTC
active_until: string | null // ISO UTC active_until: string | null // ISO UTC
} }
@@ -65,6 +106,8 @@ export interface UserData {
hl_api_key_masked: string | null hl_api_key_masked: string | null
trades: BotTrade[] trades: BotTrade[]
settings: UserSettings settings: UserSettings
/** ISO-UTC timestamp until which the bot is manually armed (null = use schedule). */
manual_window_until: string | null
} }
export async function setUserSettings( export async function setUserSettings(
@@ -81,6 +124,181 @@ export interface UserPublic {
wallet_address: string wallet_address: string
active: boolean active: boolean
hl_api_key_set: boolean hl_api_key_set: boolean
/** Phase 1 operational state — surfaced on /signals page. */
paper_mode?: boolean
manual_window_until?: string | null
circuit_breaker_tripped_at?: string | null
circuit_breaker_reason?: string | null
/** Master Auto-Trade gate. false (default) = signals shown, not traded. */
auto_trade?: boolean
}
export interface SignalSource {
source: string
count: number
latest: string | null
}
export async function getSignalSources(): Promise<{ sources: SignalSource[] }> {
return fetchJson<{ sources: SignalSource[] }>(`/signals/sources`)
}
/**
* Toggle paper mode for a wallet. Hits the dev endpoint — no signature
* required. Only available in development environments where the dev router
* is mounted; in production this will 404.
*/
export async function setPaperMode(
wallet: string,
enabled: boolean,
): Promise<{ status: string; paper_mode: boolean }> {
return fetchJson<{ status: string; paper_mode: boolean }>(
`/dev/paper-mode?wallet=${wallet.toLowerCase()}&enabled=${enabled}`,
{ method: 'POST' },
)
}
// ─── Open positions + today stats ───────────────────────────────────────────
export interface OpenPosition {
trade_id: number
asset: string
side: 'long' | 'short'
entry_price: number
current_price: number | null
size_usd: number | null
leverage: number | null
opened_at: string
hold_minutes: number
unrealized_pct: number | null
unrealized_usd: number | null
is_paper: boolean
trigger_post_id: number | null
/** System-2 staged lifecycle. size_usd above is the OPEN notional after
* de-risk; realized_usd is PnL already banked by partial de-risk. */
realized_usd?: number | null
derisk_steps?: number
addon_steps?: number
/** Per-trade pyramiding switch. */
grow_mode?: boolean
}
export interface OpenPositionsResponse {
wallet: string
count: number
positions: OpenPosition[]
}
export interface TodayStats {
wallet: string
realized_pnl_usd: number
trades_closed: number
wins: number
losses: number
open_count: number
/** Cumulative PnL banked by staged de-risk on still-open positions.
* Shown separately — not part of realized_pnl_usd (closed trades only). */
open_realized_usd?: number
}
export async function getOpenPositions(
wallet: string,
env: SignedEnvelope,
): Promise<OpenPositionsResponse> {
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
return fetchJson<OpenPositionsResponse>(`/positions/open?${qs}`)
}
export async function getTodayStats(
wallet: string,
env: SignedEnvelope,
): Promise<TodayStats> {
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
return fetchJson<TodayStats>(`/positions/today?${qs}`)
}
// ─── Scanner control (module 2) ─────────────────────────────────────────────
export interface ScannerSummary {
name: string
enabled: boolean
last_run_at: string | null
last_status: string
last_message: string | null
last_fired_at: string | null
total_runs: number
total_fires: number
consecutive_errors: number
}
export interface ScannersResponse {
count: number
enabled: number
scanners: ScannerSummary[]
}
export async function getScanners(): Promise<ScannersResponse> {
return fetchJson<ScannersResponse>(`/scanners`)
}
/**
* Toggle ONE scanner. Signed + subscriber-gated on the backend: the wallet
* must own the signature AND already be a subscriber. The signed body is
* { enabled, name } — must match the backend's canonical hash exactly.
*/
export async function toggleScanner(
env: SignedEnvelope,
name: string,
enabled: boolean,
): Promise<{ status: string; name: string; enabled: boolean }> {
return fetchJson<{ status: string; name: string; enabled: boolean }>(
`/scanners/${name}/toggle`,
{
method: 'POST',
body: JSON.stringify({ ...env, name, enabled }),
},
)
}
export async function killAllScanners(
env: SignedEnvelope,
): Promise<{ status: string; count: number }> {
return fetchJson<{ status: string; count: number }>(
`/scanners/all/disable`,
{ method: 'POST', body: JSON.stringify(env) },
)
}
export async function reviveAllScanners(
env: SignedEnvelope,
): Promise<{ status: string; count: number }> {
return fetchJson<{ status: string; count: number }>(
`/scanners/all/enable`,
{ method: 'POST', body: JSON.stringify(env) },
)
}
export interface CloseTradeResponse {
status: string
trade_id: number
exit_price: number | null
pnl_usd: number | null
reason: string
note: string | null
}
/**
* Manual emergency close. Requires a signed envelope produced for the action
* "close_trade" with body { trade_id }. The backend re-checks wallet ownership.
*/
export async function manualCloseTrade(
env: SignedEnvelope,
trade_id: number,
): Promise<CloseTradeResponse> {
return fetchJson<CloseTradeResponse>(`/positions/${trade_id}/close`, {
method: 'POST',
body: JSON.stringify({ ...env, trade_id }),
})
} }
/** No-auth boolean state. Safe to call on every page load. */ /** No-auth boolean state. Safe to call on every page load. */
@@ -97,6 +315,29 @@ export async function getUser(
return fetchJson<UserData>(`/user/${wallet.toLowerCase()}?${qs}`) return fetchJson<UserData>(`/user/${wallet.toLowerCase()}?${qs}`)
} }
export interface WindowAccuracy {
checked: number
correct: number
accuracy_pct: number
}
export interface SignalAccuracyEntry {
count: number
m5: WindowAccuracy
m15: WindowAccuracy
m1h: WindowAccuracy
}
export interface SignalAccuracy {
overall: { m5: WindowAccuracy; m15: WindowAccuracy; m1h: WindowAccuracy }
by_signal: Record<string, SignalAccuracyEntry>
total_directional_signals: number
}
export async function getSignalAccuracy(): Promise<SignalAccuracy> {
return fetchJson<SignalAccuracy>(`/signals/accuracy`)
}
export async function setHlApiKey( export async function setHlApiKey(
env: SignedEnvelope, env: SignedEnvelope,
api_key: string, api_key: string,
@@ -109,3 +350,173 @@ export async function setHlApiKey(
}, },
) )
} }
/**
* Arm (or clear) the manual-window override. Pass `hours = 0` to disarm.
* Backend stores `manual_window_until = now + hours`; when in the future the
* bot trades regardless of the schedule.
*/
/** Master Auto-Trade switch (signed). enabled=true also acknowledges +
* clears a tripped circuit breaker. */
export async function setAutoTrade(
env: SignedEnvelope,
enabled: boolean,
): Promise<{ auto_trade: boolean; circuit_breaker_cleared: boolean }> {
return fetchJson<{ auto_trade: boolean; circuit_breaker_cleared: boolean }>(
`/user/${env.wallet}/auto-trade`,
{ method: 'POST', body: JSON.stringify({ ...env, enabled }) },
)
}
/** Per-trade Grow (pyramiding) switch (signed). */
export async function setTradeGrow(
env: SignedEnvelope,
trade_id: number,
enabled: boolean,
): Promise<{ trade_id: number; grow_mode: boolean }> {
return fetchJson<{ trade_id: number; grow_mode: boolean }>(
`/positions/${trade_id}/grow`,
{ method: 'POST', body: JSON.stringify({ ...env, trade_id, enabled }) },
)
}
export async function setManualWindow(
env: SignedEnvelope,
hours: number,
): Promise<{ manual_window_until: string | null }> {
return fetchJson<{ manual_window_until: string | null }>(
`/user/${env.wallet}/manual-window`,
{
method: 'POST',
body: JSON.stringify({ ...env, hours }),
},
)
}
// ── KOL module ────────────────────────────────────────────────────
export async function getKolPosts(opts: {
handle?: string; source?: string; limit?: number; page?: number
} = {}): Promise<{ items: KolPostSummary[]; page: number; limit: number }> {
const p = new URLSearchParams()
if (opts.handle) p.set('handle', opts.handle)
if (opts.source) p.set('source', opts.source)
p.set('limit', String(opts.limit ?? 50))
p.set('page', String(opts.page ?? 1))
return fetchJson(`/kol/posts?${p.toString()}`)
}
export async function getKolPost(id: number): Promise<KolPostDetail> {
return fetchJson<KolPostDetail>(`/kol/posts/${id}`)
}
export async function getKolDigest(days = 7): Promise<KolDigest> {
return fetchJson<KolDigest>(`/kol/digest?days=${days}`)
}
export async function getKolWallets(handle?: string): Promise<{ wallets: KolWallet[] }> {
const p = handle ? `?handle=${handle}` : ''
return fetchJson<{ wallets: KolWallet[] }>(`/kol/wallets${p}`)
}
export async function addKolWallet(body: {
handle: string; chain: string; address: string; label?: string; source_url?: string
}): Promise<KolWallet> {
return fetchJson<KolWallet>(`/kol/wallets`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function getKolChanges(opts: {
handle?: string; days?: number
} = {}): Promise<{ window_days: number; since: string; count: number; changes: KolHoldingChange[] }> {
const p = new URLSearchParams()
if (opts.handle) p.set('handle', opts.handle)
p.set('days', String(opts.days ?? 7))
return fetchJson(`/kol/changes?${p.toString()}`)
}
export async function getKolDivergence(opts: {
handle?: string; ticker?: string; signal_type?: 'divergence' | 'alignment'; days?: number
} = {}): Promise<{ window_days: number; since: string; count: number; items: KolDivergence[] }> {
const p = new URLSearchParams()
if (opts.handle) p.set('handle', opts.handle)
if (opts.ticker) p.set('ticker', opts.ticker)
if (opts.signal_type) p.set('signal_type', opts.signal_type)
p.set('days', String(opts.days ?? 30))
return fetchJson(`/kol/divergence?${p.toString()}`)
}
// ── Funding rate reversal (BTC tab) ─────────────────────────────────────────
export interface FundingSnapshot {
ok: boolean
error?: string
asset?: string
cadence_hours?: number
coverage_days?: number
latest_rate_pct?: number
last_24h_avg_pct?: number
cum_30d_pct?: number
extreme_threshold_pct?: number
signal_fired?: boolean
debug?: { reason?: string; direction?: string; [k: string]: unknown }
history?: { t: number; rate_pct: number }[]
}
export async function getFundingSnapshot(): Promise<FundingSnapshot> {
return fetchJson<FundingSnapshot>('/funding/snapshot')
}
// ── Telegram alerts ─────────────────────────────────────────────────────────
export interface TelegramStatus {
configured: boolean
bot_username: string | null
bound: boolean
wallet_address?: string | null
tg_username?: string | null
chat_id?: number | null
alerts_enabled?: boolean | null
alert_trump?: boolean | null
alert_btc_bottom?: boolean | null
alert_funding?: boolean | null
alert_kol_divergence?: boolean | null
min_confidence?: number | null
mute_from_hour?: number | null
mute_until_hour?: number | null
total_alerts_sent?: number | null
}
export interface TelegramInitResp {
code: string; deep_link: string; expires_in_seconds: number
}
export async function getTelegramStatus(wallet: string): Promise<TelegramStatus> {
return fetchJson<TelegramStatus>(`/telegram/${wallet}/status`)
}
// SignedEnvelope is already imported at the top of this file. Below we
// reuse it directly for the Telegram helpers — no separate alias needed.
export async function tgInit(wallet: string, env: SignedEnvelope): Promise<TelegramInitResp> {
return fetchJson(`/telegram/${wallet}/init`, {
method: 'POST', body: JSON.stringify(env),
})
}
export async function tgUnbind(wallet: string, env: SignedEnvelope): Promise<{ removed: number }> {
return fetchJson(`/telegram/${wallet}/unbind`, {
method: 'POST', body: JSON.stringify(env),
})
}
export async function tgTest(wallet: string, env: SignedEnvelope): Promise<{ sent: boolean }> {
return fetchJson(`/telegram/${wallet}/test`, {
method: 'POST', body: JSON.stringify(env),
})
}
export async function tgPreferences(
wallet: string,
env: SignedEnvelope,
prefs: Partial<{
alerts_enabled: boolean; alert_trump: boolean; alert_btc_bottom: boolean
alert_funding: boolean; alert_kol_divergence: boolean
min_confidence: number; mute_from_hour: number; mute_until_hour: number
}>,
): Promise<TelegramStatus> {
return fetchJson(`/telegram/${wallet}/preferences`, {
method: 'POST', body: JSON.stringify({ ...env, ...prefs }),
})
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Lightweight in-memory SWR cache for API data.
*
* Lives at module scope — persists across React renders and page navigations
* within the same browser tab, but clears on hard-refresh / tab close.
*
* Usage:
* const data = await swrFetch('posts-500', 3 * 60_000, () => getPosts(500))
*
* On first call → fetches, caches, returns.
* On subsequent calls within TTL → returns cached data immediately.
* On calls after TTL → returns stale data immediately AND triggers a
* background revalidation; the `onUpdate` callback fires when fresh
* data arrives so the component can re-render without a loading flash.
*/
interface CacheEntry<T> {
data: T
ts: number
revalidating: boolean
}
const store = new Map<string, CacheEntry<unknown>>()
export async function swrFetch<T>(
key: string,
ttlMs: number,
fetcher: () => Promise<T>,
onUpdate?: (fresh: T) => void,
): Promise<T> {
const now = Date.now()
const hit = store.get(key) as CacheEntry<T> | undefined
if (hit) {
const age = now - hit.ts
if (age < ttlMs) {
// Still fresh — return immediately.
return hit.data
}
// Stale — return immediately AND revalidate in background.
if (!hit.revalidating) {
hit.revalidating = true
fetcher()
.then(fresh => {
store.set(key, { data: fresh, ts: Date.now(), revalidating: false })
onUpdate?.(fresh)
})
.catch(() => {
if (hit) hit.revalidating = false
})
}
return hit.data
}
// Cache miss — must wait for the first fetch.
const data = await fetcher()
store.set(key, { data, ts: Date.now(), revalidating: false })
return data
}
/** Manually invalidate a cache key (e.g. after a write action). */
export function invalidate(key: string) {
store.delete(key)
}
/** Peek at whether a key has any cached data (stale or fresh). */
export function hasCached(key: string): boolean {
return store.has(key)
}
+1 -1
View File
@@ -53,7 +53,7 @@ export interface SignedEnvelope {
* within a 4-minute window (server accepts 5-min skew). Backend's replay-guard * within a 4-minute window (server accepts 5-min skew). Backend's replay-guard
* is disabled for the view action so the same sig can be used multiple times. * is disabled for the view action so the same sig can be used multiple times.
*/ */
const VIEW_TTL_MS = 4 * 60 * 1000 const VIEW_TTL_MS = 20 * 60 * 1000 // 20 min — backend's replay-guard is disabled for view actions
/** /**
* Read-only: returns a cached, not-yet-expired view envelope if one exists. * Read-only: returns a cached, not-yet-expired view envelope if one exists.
+16 -51
View File
@@ -1,62 +1,27 @@
'use client' 'use client'
import { useEffect, useRef, useCallback } from 'react' import { useWsSubscribe } from './wsContext'
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000'
type PriceUpdate = { type: 'price'; asset: 'BTC' | 'ETH'; price: number }
type PostUpdate = { type: 'new_post'; post: object }
type WsMessage = PriceUpdate | PostUpdate
interface Handlers { interface Handlers {
onPrice?: (asset: 'BTC' | 'ETH', price: number) => void onPrice?: (asset: 'BTC' | 'ETH', price: number) => void
onNewPost?: (post: object) => void onNewPost?: (post: object) => void
} }
type PriceMsg = { type: 'price'; asset: 'BTC' | 'ETH'; price: number }
type PostMsg = { type: 'new_post'; post: object }
/**
* Subscribe to price ticks and new-post events from the shared WebSocket.
* Delegates to WsProvider's singleton connection — no new socket is opened.
*/
export function usePriceSocket(handlers: Handlers) { export function usePriceSocket(handlers: Handlers) {
const wsRef = useRef<WebSocket | null>(null) useWsSubscribe('price', (msg) => {
const handlersRef = useRef(handlers) const m = msg as PriceMsg
const unmountedRef = useRef(false) handlers.onPrice?.(m.asset, m.price)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) })
handlersRef.current = handlers
const connect = useCallback(() => { useWsSubscribe('new_post', (msg) => {
if (unmountedRef.current) return const m = msg as PostMsg
const ws = new WebSocket(`${WS_URL}/ws/prices`) handlers.onNewPost?.(m.post)
wsRef.current = ws })
ws.onmessage = (e) => {
try {
const msg: WsMessage = JSON.parse(e.data)
if (msg.type === 'price' && handlersRef.current.onPrice) {
handlersRef.current.onPrice(msg.asset, msg.price)
} else if (msg.type === 'new_post' && handlersRef.current.onNewPost) {
handlersRef.current.onNewPost(msg.post)
}
} catch (err) {
console.warn('[WS] malformed message:', e.data, err)
}
}
ws.onclose = () => {
// Don't schedule a reconnect if the component already unmounted —
// otherwise we leak a socket per navigation forever.
if (unmountedRef.current) return
reconnectTimerRef.current = setTimeout(connect, 3000)
}
ws.onerror = () => {
ws.close()
}
}, [])
useEffect(() => {
unmountedRef.current = false
connect()
return () => {
unmountedRef.current = true
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
wsRef.current?.close()
}
}, [connect])
} }
+11 -1
View File
@@ -1,4 +1,4 @@
import { createConfig, http } from 'wagmi' import { createConfig, createStorage, http } from 'wagmi'
import { mainnet } from 'wagmi/chains' import { mainnet } from 'wagmi/chains'
import { metaMask } from 'wagmi/connectors' import { metaMask } from 'wagmi/connectors'
@@ -10,4 +10,14 @@ export const config = createConfig({
transports: { transports: {
[mainnet.id]: http(), [mainnet.id]: http(),
}, },
// sessionStorage: clears on tab close, prevents stale auto-reconnect across sessions.
// This means MetaMask only pops when the user explicitly clicks "Connect wallet".
storage: typeof window !== 'undefined'
? createStorage({ storage: window.sessionStorage })
: undefined,
// Disable silent auto-reconnect on page mount.
// Without this, wagmi checks localStorage/sessionStorage for a saved connector
// and tries to reconnect MetaMask silently — which can pop the MetaMask window.
// User must explicitly click "Connect wallet" each session.
ssr: true,
}) })
+39
View File
@@ -0,0 +1,39 @@
/**
* MetaMask / EIP-1193 wallet error helpers.
*
* Previously the codebase detected user rejections by string-matching the
* error message for "reject" or "denied". That breaks when MetaMask's UI
* language is set to anything other than English (the message is translated
* but our match string isn't). The reliable signal is the EIP-1193 error
* code: 4001 = "User rejected the request".
*
* Wagmi sometimes wraps the underlying provider error inside `.cause`, so we
* walk that chain too.
*/
interface WithCode { code?: number | string; cause?: unknown }
export function isUserRejection(err: unknown): boolean {
// Walk a small depth of .cause chains since wagmi/viem wrap provider errors
let cur: unknown = err
for (let i = 0; i < 4 && cur; i++) {
const c = (cur as WithCode)?.code
if (c === 4001 || c === 'ACTION_REJECTED' || c === 'USER_REJECTED') return true
cur = (cur as WithCode)?.cause
}
// Last-resort string fallback for providers that don't expose a code field.
// Kept narrow — "reject" + "denied" + a couple of locale variants we've
// actually seen from MetaMask zh-CN ("拒绝") and Rabby ("rejected by user").
const m = err instanceof Error ? err.message : ''
return /reject|denied|cancell|拒绝|已取消/i.test(m)
}
/**
* Convenience: return either a friendly cancellation label or a truncated
* version of the real error message — never raw user-facing crash text.
*/
export function walletErrorLabel(err: unknown, cancelledLabel = 'Cancelled', maxLen = 110): string {
if (isUserRejection(err)) return cancelledLabel
const m = err instanceof Error ? err.message : String(err ?? '')
return m.slice(0, maxLen) || 'unknown error'
}
+110
View File
@@ -0,0 +1,110 @@
'use client'
/**
* Singleton WebSocket provider.
*
* The entire app shares ONE connection to /ws/prices. Components subscribe
* to specific message types via `useWsSubscribe` — no component owns the socket.
*
* Why: previously DashboardClient (via usePriceSocket) and SignalMonitor each
* opened their own WebSocket, giving the server two connections per page load
* and causing each component to receive every broadcast twice.
*/
import {
createContext,
useContext,
useEffect,
useRef,
type ReactNode,
} from 'react'
type Handler = (msg: unknown) => void
interface WsContextValue {
subscribe: (type: string, fn: Handler) => () => void
}
const WsContext = createContext<WsContextValue | null>(null)
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000'
export function WsProvider({ children }: { children: ReactNode }) {
// Stable map: message-type → set of handlers. Never replaced, only mutated.
const subs = useRef<Map<string, Set<Handler>>>(new Map())
useEffect(() => {
let dead = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
// Hold the current socket at useEffect scope so cleanup can close it.
// Previously `ws` lived inside connect() and cleanup couldn't reach it,
// leaking one connection per StrictMode remount and one per [locale]
// layout swap (which remounts WsProvider).
let socket: WebSocket | null = null
function connect() {
if (dead) return
socket = new WebSocket(`${WS_URL}/ws/prices`)
const ws = socket // local alias for handler closures
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data) as { type?: string }
const t = msg.type ?? '__unknown__'
subs.current.get(t)?.forEach((fn) => fn(msg))
} catch {
// ignore malformed frames
}
}
ws.onclose = () => {
if (!dead) retryTimer = setTimeout(connect, 3000)
}
ws.onerror = () => ws.close()
}
connect()
return () => {
dead = true
if (retryTimer) clearTimeout(retryTimer)
// Actively close any live socket so the OS-level connection releases
// immediately on unmount instead of waiting for the next server keepalive.
if (socket && socket.readyState <= WebSocket.OPEN) {
socket.onclose = null // suppress the auto-reconnect we'd otherwise queue
socket.close()
}
socket = null
}
}, [])
function subscribe(type: string, fn: Handler): () => void {
if (!subs.current.has(type)) subs.current.set(type, new Set())
subs.current.get(type)!.add(fn)
return () => subs.current.get(type)?.delete(fn)
}
return <WsContext.Provider value={{ subscribe }}>{children}</WsContext.Provider>
}
/**
* Subscribe to a specific WebSocket message type from the shared connection.
*
* @param type The `msg.type` string to listen for (e.g. 'price', 'new_post', 'funding_signal')
* @param handler Called with the full parsed message object on every matching frame.
* Always receives the latest handler reference — no stale-closure issues.
*
* Usage:
* useWsSubscribe('price', (msg) => { ... })
*/
export function useWsSubscribe(type: string, handler: Handler): void {
const ctx = useContext(WsContext)
// Keep a ref so the subscription closure never goes stale even if handler
// is an inline function that changes every render.
const handlerRef = useRef<Handler>(handler)
handlerRef.current = handler
useEffect(() => {
if (!ctx) return
return ctx.subscribe(type, (msg) => handlerRef.current(msg))
}, [ctx, type]) // type is stable in practice; ctx never changes
}
+23 -63
View File
@@ -1,68 +1,28 @@
{ {
"nav": { "nav": {
"overview": "Overview", "brand": "Trump Alpha",
"posts": "Posts", "tabs": {
"trades": "Trades", "overview": "Overview",
"analytics": "Analytics", "trump": "Trump",
"settings": "Settings", "btc": "BTC",
"connectWallet": "Connect wallet", "kol": "KOL",
"language": "EN" "trades": "Trades",
"analytics": "Analytics",
"settings": "Settings"
},
"actions": {
"connectWallet": "Connect wallet",
"copyAddress": "Copy address",
"copied": "Copied",
"disconnect": "Disconnect"
}
}, },
"kpi": { "footer": {
"btcPrice": "BTC Price", "methodology": "Methodology",
"ethPrice": "ETH Price", "glossary": "Glossary",
"postsTracked": "Posts Tracked", "caseStudies": "Case Studies",
"avgMove": "Avg Move" "privacy": "Privacy",
}, "terms": "Terms",
"bot": { "contact": "Contact"
"title": "Bot Performance",
"winRate": "Win Rate",
"netPnl": "Net PnL",
"totalTrades": "Trades",
"avgHold": "Avg Hold",
"connectCta": "Let our AI trade for you",
"connectDesc": "Connect your wallet to enable automated trading signals based on Trump's social posts.",
"subscribeBtn": "Subscribe — $99/mo",
"settingsTitle": "Bot Settings",
"apiKeyLabel": "API Key",
"apiKeyPlaceholder": "Paste your exchange API key",
"connectWalletFree": "Connect wallet — free",
"activeStatus": "Active",
"saveKey": "Save",
"period": "30d"
},
"trades": {
"title": "Recent Bot Trades",
"asset": "Asset",
"side": "Side",
"entry": "Entry",
"exit": "Exit",
"pnl": "PnL",
"hold": "Hold",
"trigger": "Trigger"
},
"posts": {
"title": "Trump Posts",
"sentimentLabel": "Sentiment",
"sourceLabel": "Source",
"confidenceLabel": "AI Confidence",
"impactLabel": "Price Impact",
"comingSoon": "Full post feed coming soon."
},
"analytics": {
"title": "Analytics",
"comingSoon": "Analytics coming soon."
},
"settings": {
"title": "Settings",
"connectRequired": "Connect your wallet to access settings.",
"comingSoon": "Settings coming soon."
},
"common": {
"bullish": "Bullish",
"bearish": "Bearish",
"neutral": "Neutral",
"loading": "Loading...",
"comingSoon": "Coming soon"
} }
} }
+28
View File
@@ -0,0 +1,28 @@
{
"nav": {
"brand": "Trump Alpha",
"tabs": {
"overview": "总览",
"trump": "Trump",
"btc": "BTC",
"kol": "KOL",
"trades": "交易",
"analytics": "分析",
"settings": "设置"
},
"actions": {
"connectWallet": "连接钱包",
"copyAddress": "复制地址",
"copied": "已复制",
"disconnect": "断开连接"
}
},
"footer": {
"methodology": "方法论",
"glossary": "术语表",
"caseStudies": "案例研究",
"privacy": "隐私政策",
"terms": "服务条款",
"contact": "联系我们"
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2140 -1074
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -2,33 +2,36 @@
"name": "trumpsignal", "name": "trumpsignal",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"engines": {
"node": ">=20.19.0"
},
"scripts": { "scripts": {
"dev": "next dev -p 3001", "dev": "next dev -p 3001",
"build": "next build", "build": "next build",
"start": "next start -p 3001", "start": "next start -p 3001",
"lint": "next lint" "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"next": "14.2.5",
"react": "^18",
"react-dom": "^18",
"next-intl": "^3.15.3",
"lightweight-charts": "^4.1.3",
"wagmi": "^2.10.5",
"viem": "^2.17.0",
"@rainbow-me/rainbowkit": "^2.1.3", "@rainbow-me/rainbowkit": "^2.1.3",
"@tanstack/react-query": "^5.40.0", "@tanstack/react-query": "^5.40.0",
"lightweight-charts": "^4.1.3",
"next": "16.2.6",
"next-intl": "4.12.0",
"react": "^18",
"react-dom": "^18",
"viem": "^2.17.0",
"wagmi": "^2.10.5",
"zustand": "^4.5.3" "zustand": "^4.5.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"typescript": "^5",
"tailwindcss": "^3.4.4",
"postcss": "^8",
"autoprefixer": "^10", "autoprefixer": "^10",
"eslint": "^8", "eslint": "9.39.4",
"eslint-config-next": "14.2.5" "eslint-config-next": "16.2.6",
"postcss": "^8",
"tailwindcss": "^3.4.4",
"typescript": "^5"
} }
} }
+1 -1
View File
@@ -10,5 +10,5 @@ export default createMiddleware({
export const config = { export const config = {
// Only match locale-prefixed routes. `/` is served by `app/page.tsx` // Only match locale-prefixed routes. `/` is served by `app/page.tsx`
// (the landing page) and must not be intercepted by the i18n middleware. // (the landing page) and must not be intercepted by the i18n middleware.
matcher: ['/(en)/:path*'], matcher: ['/(en|zh)/:path*'],
} }
+60
View File
@@ -0,0 +1,60 @@
# Trump Alpha
> AI-powered crypto intelligence platform. Tracks four uncorrelated signal sources: Trump Truth Social sentiment, on-chain Bitcoin bottom reversal, KOL Substack/podcast signals, and talks-vs-trades divergence.
## What this site does
Trump Alpha aggregates six independent crypto signal engines into one live dashboard:
1. **Trump Truth Social Signal** — Scrapes Trump's posts within 15 seconds of publish. AI classifies each as LONG (bullish crypto), SHORT (bearish), or NOISE (off-topic). Optional auto-trade on Hyperliquid with isolated margin, automatic TP/SL, and six user-defined safety limits.
2. **BTC Bottom Reversal** — Price-confluence scanner: fires a long-only buy signal when at least 2 of 3 classic macro-bottom signals agree — AHR999 < 0.45 (deep-value zone), price ≤ 200-week moving average × 1.05, Pi Cycle Bottom (150-day EMA ≤ 471-day SMA × 0.745). No API keys, no on-chain data, no Glassnode dependency. Occurs 24 times per market cycle. Position is invalidated when AHR999 climbs back above 1.2.
3. **KOL Signal** — 19 crypto KOL feeds including Arthur Hayes, Delphi Digital, Dragonfly Capital, Bankless, Empire, Unchained, 0xResearch, Lightspeed, Pomp, The Defiant, Reflexivity Research (Will Clemente), Bell Curve (Multicoin), The Scoop (The Block), TFTC (Marty Bent). Posts and podcast episodes ingested daily via RSS. AI extracts ticker, direction (buy/sell/bullish/bearish), and conviction (01 float).
4. **Talks-vs-Trades Divergence** — Cross-references KOL public posts against their on-chain Ethereum wallet changes within a ±7-day window. Divergence fires when a KOL is publicly bullish but their wallet is selling (or vice versa). On-chain action is treated as ground truth. This is the platform's highest-conviction signal category.
5. **Funding Rate Reversal** — Monitors perpetual funding rates across major exchanges. Extreme positive or negative funding historically precedes mean-reversion. Signals fire when rates cross multi-cycle thresholds.
6. **Hyperliquid Auto-Trader** — Optional execution layer for the Trump pillar. Non-custodial: uses a trade-only API key (cannot withdraw). Configurable leverage, position size, TP/SL, daily cap, and active hours.
## Key concepts defined
**AHR999**: A composite Bitcoin valuation indicator combining price, the geometric mean of historical price, and an exponential growth model. Below 0.45 marks the deep-value / accumulation zone; above 1.2 invalidates a bottom thesis (BTC is no longer cheap).
**200-week Moving Average (200WMA)**: A long-cycle structural support — historically every Bitcoin bear cycle has bottomed within ±5% of this level. We require price ≤ 200WMA × 1.05 as one of the three macro-bottom triggers.
**Pi Cycle Bottom**: 150-day EMA falling to ≤ 471-day SMA × 0.745. A late-cycle capitulation signal — historically pinpointed the 2015, 2018, and 2022 BTC bottoms within a 2-week window.
**Talks-vs-Trades**: The practice of comparing a KOL's public verbal stance on an asset against their actual on-chain wallet behavior. Divergence between the two is a high-value contrarian signal.
**KOL (Key Opinion Leader)**: In crypto, influential analysts, fund managers, and content creators whose public views move retail sentiment.
## Pricing
Free to read all signals. No subscription. No account required for dashboards.
Optional: bring your own Hyperliquid account for auto-trading.
## Technology
- Backend: Python / FastAPI
- AI: Claude (Anthropic) for signal extraction
- On-chain: Etherscan API + Hyperliquid public API
- Frontend: Next.js 14
## URLs
- Homepage / landing: https://trumpsignal.com
- Trump signal dashboard: https://trumpsignal.com/en/trump
- BTC bottom reversal: https://trumpsignal.com/en/btc
- KOL signals & divergence: https://trumpsignal.com/en/kol
- Signal methodology (full technical detail): https://trumpsignal.com/en/methodology
- Glossary (MVRV-Z, STH-SOPR, KOL, divergence...): https://trumpsignal.com/en/glossary
- Case studies (documented Trump crypto events): https://trumpsignal.com/en/case-studies
- Settings / auto-trader config: https://trumpsignal.com/en/settings
- FAQ: https://trumpsignal.com/#faq
- About / canonical definition: https://trumpsignal.com/#about
## Contact
Site: https://trumpsignal.com
+19 -5
View File
@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@@ -19,9 +23,19 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
"./*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }
+121 -2
View File
@@ -1,7 +1,10 @@
export interface TrumpPost { export interface TrumpPost {
id: number id: number
text: string text: string
source: 'x' | 'truth' // Was narrowed to 'x' | 'truth' when Trump was the only source. Now any string —
// external signal modules POST to /api/signals/ingest with arbitrary source tags
// (e.g. 'breakout', 'vcp_breakout', user's own strategy name).
source: string
published_at: string published_at: string
sentiment: 'bullish' | 'bearish' | 'neutral' sentiment: 'bullish' | 'bearish' | 'neutral'
signal: 'buy' | 'sell' | 'short' | 'hold' | null signal: 'buy' | 'sell' | 'short' | 'hold' | null
@@ -10,6 +13,10 @@ export interface TrumpPost {
prefilter_reason: 'rt_only' | 'url_only' | 'empty' | 'parse_error' | 'api_error' | null prefilter_reason: 'rt_only' | 'url_only' | 'empty' | 'parse_error' | 'api_error' | null
analysis_version: string | null analysis_version: string | null
relevant: boolean relevant: boolean
// v5 routing fields — null on pre-v5 posts
target_asset: string | null
category: string | null
expected_move_pct: number | null
price_impact: { price_impact: {
asset: 'BTC' | 'ETH' asset: 'BTC' | 'ETH'
m5: number | null // null = window still open (live rolling peak pending) m5: number | null // null = window still open (live rolling peak pending)
@@ -33,7 +40,8 @@ export interface Candle {
export interface BotTrade { export interface BotTrade {
id: number id: number
asset: 'BTC' | 'ETH' // Was narrowed to BTC/ETH; v5 routes trades to arbitrary HL perps (SOL, etc.).
asset: string
side: 'long' | 'short' side: 'long' | 'short'
entry_price: number entry_price: number
exit_price: number exit_price: number
@@ -42,6 +50,10 @@ export interface BotTrade {
trigger_post_id: number trigger_post_id: number
opened_at: string opened_at: string
closed_at: string closed_at: string
/** Source of the triggering signal — 'truth' | 'breakout' | user's module name. */
trigger_source?: string | null
/** True iff this was a paper-mode trade (no Hyperliquid call). */
is_paper?: boolean
} }
export interface BotPerformance { export interface BotPerformance {
@@ -52,3 +64,110 @@ export interface BotPerformance {
avg_hold_seconds: number avg_hold_seconds: number
max_drawdown_pct: number max_drawdown_pct: number
} }
// ── KOL module ────────────────────────────────────────────────────
export type KolAction = 'buy' | 'sell' | 'bullish' | 'bearish' | 'mention'
export interface KolTicker {
ticker: string
action: KolAction
conviction: number
quote: string
}
export interface KolPostSummary {
id: number
kol_handle: string
source: 'substack' | 'twitter' | string
url: string
title: string | null
published_at: string
summary: string | null
tickers: KolTicker[]
analyzed_at: string | null
analysis_model: string | null
}
export interface KolPostDetail extends KolPostSummary {
raw_text: string
}
export interface KolDigestCall {
post_id: number
kol_handle: string
post_title: string | null
published_at: string
action: KolAction
conviction: number
quote: string
}
export interface KolDigestTicker {
ticker: string
dominant_action: KolAction
side: 'long' | 'short' | 'neutral'
max_conviction: number
post_count: number
kol_count: number
kols: string[]
calls: KolDigestCall[]
}
export interface KolDigest {
window_days: number
since: string
post_count: number
ticker_count: number
tickers: KolDigestTicker[]
}
// ── KOL A-tier: on-chain holdings ─────────────────────────────────────────
export interface KolWallet {
id: number
handle: string
chain: string // ethereum | solana | hl
address: string
label: string | null
source_url: string | null
active: boolean
added_at: string
}
export interface KolHolding {
ticker: string
amount: number
usd_value: number
chain: string
side?: 'long' | 'short' // HL perps only
}
export interface KolHoldingChange {
id: number
wallet_id: number
handle: string
detected_at: string
ticker: string
change_type: 'new_position' | 'closed' | 'increased' | 'decreased'
usd_before: number | null
usd_after: number | null
pct_change: number | null
}
export interface KolDivergence {
id: number
handle: string
ticker: string
signal_type: 'divergence' | 'alignment'
direction: 'long' | 'short'
post_id: number
post_action: string // buy | sell | bullish | bearish
post_conviction: number | null
post_at: string
onchain_action: string // new_position | increased | decreased | closed
usd_before: number | null
usd_after: number | null
onchain_at: string
days_apart: number | null
created_at: string
}