feat(macro-vibes): rename BTC Signal → Macro Vibes; add MacroPanel UI

Module rename across page H1, navbar tab, URL (/en/btc → /en/macro),
all metadata/JSON-LD, sitemap, llms.txt, opengraph image, and SystemControl
copy. Old /btc route fully removed.

New components/btc/MacroPanel.tsx polls /api/macro/snapshot and lays out
the 8 indicators in four sections (Valuation / Bottom trigger reference /
Market structure / Sentiment & flows / Positioning) with tone-coloured
values, current-band threshold chips, and a single CoinGlass / source
chart link per card. Composite -100..+100 needle pulses on score change.

Also fixes the pinned bottom-reversal alert on the homepage, which still
linked to the now-404 /en/btc — now routes to /en/macro.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-26 01:05:18 +08:00
parent d72323b1c6
commit f34ae9eb00
33 changed files with 1810 additions and 228 deletions
+9 -7
View File
@@ -13,6 +13,7 @@ import ChartPanel from '@/components/dashboard/ChartPanel'
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
import SignalMonitor from '@/components/dashboard/SignalMonitor'
import OpenPositions from '@/components/positions/OpenPositions'
import PageHint from '@/components/ui/PageHint'
interface Props {
initialPosts: TrumpPost[]
@@ -265,13 +266,14 @@ export default function DashboardClient({ initialPosts }: Props) {
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '信号总览' : 'Signal monitor'}</h1>
<p className="page-sub">
{`${actionablePosts} actionable signals · Auto-trader ${botReadiness === 'ready' ? 'ready' : hlApiKeySet ? 'saved' : isSubscribed ? 'subscribed' : 'standby'} · ${hasPriceData ? 'Live market context' : 'Waiting for market data'}`}
</p>
<PageHint count={`${actionablePosts} actionable · ${totalPosts} tracked`}>
All four signal pipelines in one place Trump posts, BTC macro
bottom, funding-rate extremes, and KOL talks-vs-trades alongside
your auto-trader's live state.
</PageHint>
</div>
<div className="row gap-s">
<span className="chip"><span className="live-dot" />Live feed</span>
<span className="chip">{`${totalPosts} posts tracked`}</span>
</div>
</div>
@@ -279,7 +281,7 @@ export default function DashboardClient({ initialPosts }: Props) {
signal. Always sits ABOVE everything so it's never missed. */}
{btcReversalAlert && (
<a
href={`/${locale}/btc`}
href={`/${locale}/macro`}
style={{
display: 'block', textDecoration: 'none',
border: '1px solid color-mix(in oklab, var(--up) 45%, var(--line))',
@@ -290,7 +292,7 @@ export default function DashboardClient({ initialPosts }: Props) {
<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
Macro Vibes · Bottom trigger
</span>
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px',
borderRadius: 999, background: 'var(--up)', color: '#fff' }}>
@@ -301,7 +303,7 @@ export default function DashboardClient({ initialPosts }: Props) {
</span>
<span style={{ marginLeft: 'auto', fontSize: 12, fontWeight: 700,
color: 'var(--up)' }}>
Open BTC signal
Open Macro Vibes
</span>
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 6,
+23 -9
View File
@@ -7,6 +7,8 @@ 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'
import PageHint from '@/components/ui/PageHint'
import InfoTip from '@/components/ui/InfoTip'
type Period = '7d' | '30d' | '90d' | 'All'
@@ -115,12 +117,18 @@ export default function AnalyticsPageClient() {
: 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 },
{ k: isZh ? '最大回撤' : 'Max drawdown', v: filteredTrades.length ? maxDrawdown.toFixed(1) + '%' : '—', sub: isZh ? '从高点到低点的最大跌幅' : 'Worst peak-to-trough', down: true,
tip: 'Largest equity drop from a previous high. Tells you the worst losing streak you would have lived through.' },
{ k: isZh ? '总交易数' : 'Total trades', v: String(filteredTrades.length || '—'), sub: isZh ? '已平仓机器人交易' : 'Closed bot executions',
tip: 'How many closed positions in this window. Open trades not counted.' },
{ k: isZh ? '平均持仓时间' : 'Avg hold time', v: filteredTrades.length ? fmtHold(Math.round(avgHold)) : '—', sub: isZh ? '单笔计算' : 'Per trade',
tip: 'Entry → exit duration averaged across every closed trade.' },
{ k: isZh ? '平均单笔盈亏' : 'Avg trade P&L', v: fmtMoney(avgTrade), sub: isZh ? '每笔交易均值' : 'Mean per trade', up: avgTrade > 0,
tip: 'Total P&L ÷ number of trades. Positive = the strategy has edge per trade.' },
{ k: isZh ? '最佳单笔' : 'Best trade', v: fmtMoney(bestTrade), sub: isZh ? '单笔最大盈利' : 'Largest single win', up: true,
tip: 'Biggest realized gain on one trade in this window.' },
{ k: isZh ? '最差单笔' : 'Worst trade', v: fmtMoney(worstTrade), sub: isZh ? '单笔最大亏损' : 'Largest single loss', down: true,
tip: 'Biggest realized loss on one trade. Should be bounded by your stop-loss setting.' },
]
return (
@@ -128,7 +136,10 @@ export default function AnalyticsPageClient() {
<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>
<PageHint>
Did the bot actually make money? Win rate, drawdown, average trade,
and AI signal accuracy across the time window you pick on the right.
</PageHint>
</div>
<div className="nav-tabs">
{(['7d', '30d', '90d', 'All'] as const).map(p => (
@@ -154,8 +165,11 @@ export default function AnalyticsPageClient() {
<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 key={m.k} className="card" style={{ padding: 20, overflow: 'visible' }}>
<div className="tiny" style={{ marginBottom: 8, display: 'flex', alignItems: 'center' }}>
{m.k}
{m.tip && <InfoTip text={m.tip} placement="top" width={240} />}
</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>
+13 -18
View File
@@ -5,6 +5,14 @@ import { useLocale } from 'next-intl'
import type { TrumpPost } from '@/types'
import { getPosts } from '@/lib/api'
import PostRow from '@/components/dashboard/PostCards'
import PageHint from '@/components/ui/PageHint'
const LIVE_SOURCES = new Set([
'truth',
'btc_bottom_reversal',
'funding_reversal',
'kol_divergence',
])
/**
* Archive — legacy / test signals (rsi_reversal, sma_reclaim, breakout,
@@ -28,12 +36,6 @@ export default function ArchivePage() {
// 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],
@@ -53,18 +55,11 @@ export default function ArchivePage() {
<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.'}
<PageHint count={`${archivePosts.length} legacy posts`}>
Historical fires from old scanner experiments
(rsi_reversal, sma_reclaim, breakout, test/phase1). Kept only for
inspection the bot doesn't act on these any more.
</PageHint>
</div>
</div>
-71
View File
@@ -1,71 +0,0 @@
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}
/>
)
}
+7 -5
View File
@@ -271,11 +271,12 @@ function getCopy(locale: string): CaseStudiesCopy {
}
}
export function generateMetadata({
params: { locale },
export async function generateMetadata({
params,
}: {
params: { locale: string }
}): Metadata {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const copy = getCopy(locale)
return {
@@ -302,7 +303,8 @@ const SIGNAL_COLOR: Record<string, string> = {
DIVERGENCE: '#f5a524',
}
export default function CaseStudiesPage({ params: { locale } }: { params: { locale: string } }) {
export default async function CaseStudiesPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
const copy = getCopy(locale)
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
+760 -2
View File
@@ -48,8 +48,8 @@
--shadow-2: 0 4px 16px rgba(20, 18, 14, 0.06), 0 1px 2px rgba(20, 18, 14, 0.03);
--shadow-3: 0 12px 40px rgba(20, 18, 14, 0.08), 0 2px 4px rgba(20, 18, 14, 0.04);
--sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
--mono: var(--font-geist-mono), ui-monospace, 'SF Mono', Menlo, monospace;
--sans: 'Geist', ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
--mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
}
html[data-theme="dark"] {
@@ -331,6 +331,647 @@ html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2);
margin-bottom: 0;
}
/* PageHint — the prominent "what is this page" tagline directly under
.page-title. Stronger contrast + bigger than .page-sub so it actually
gets read (page-sub was so faint it functioned as decoration).
Use via <PageHint>One sentence about the page.</PageHint>. */
.page-hint {
color: var(--ink-2);
font-size: 16px;
line-height: 1.5;
margin: 10px 0 0;
max-width: 64ch;
}
.page-hint-count {
color: var(--ink-4);
font-size: 13px;
margin-left: 10px;
padding-left: 10px;
border-left: 1px solid var(--line);
}
/* SectionHint — always-visible explanation block. Pairs with a section/tab
to tell the user "what does this view actually do" in 13 sentences.
More detailed than PageHint, less detail-y than an InfoTip. Spans the
full content width on purpose — narrow boxes inside a wide page looked
like accidentally-floating callouts. */
.section-hint {
background: var(--bg-sunk);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 14px;
color: var(--ink-2);
font-size: 14px;
line-height: 1.55;
}
.section-hint strong {
color: var(--ink);
font-weight: 600;
}
/* InfoTip — small "?" pill that reveals a one-line tooltip on hover/focus.
No JS, pure CSS. Pattern mimics the (i) dots in Linear/Hyperliquid:
subtle until needed, never blocks the underlying content. */
.infotip {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
margin-left: 6px;
cursor: help;
outline: none;
/* The icon should sit on the text baseline without nudging line-height. */
line-height: 1;
}
.infotip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--bg-sunk);
color: var(--ink-3);
font-size: 10px;
font-weight: 700;
border: 1px solid var(--line);
transition: background 0.12s, color 0.12s, border-color 0.12s;
user-select: none;
}
.infotip:hover .infotip-icon,
.infotip:focus-visible .infotip-icon {
background: var(--ink);
color: var(--bg);
border-color: var(--ink);
}
.infotip-bubble {
position: absolute;
z-index: 50;
width: var(--tip-w, 240px);
padding: 8px 10px;
/* Always-dark bubble — common pattern (Linear/Stripe/GitHub) — but needs
a visible border in dark mode so it doesn't blend into the page bg. */
background: #0f0d0a;
color: #f5f2ea;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 400;
line-height: 1.5;
letter-spacing: 0;
text-transform: none;
text-align: left;
box-shadow: 0 6px 24px rgba(0,0,0,0.25), 0 1px 2px rgba(0,0,0,0.2);
pointer-events: none;
opacity: 0;
transform: translateY(2px);
transition: opacity 0.12s, transform 0.12s;
white-space: normal;
}
/* Dark mode: page bg is ~oklch(15%), bubble is #0f0d0a — visually identical.
Lighten the bubble slightly and add a 1px ring so the shape stays defined. */
html[data-theme="dark"] .infotip-bubble {
background: oklch(28% 0.008 85);
border-color: oklch(38% 0.01 85);
box-shadow: 0 6px 24px rgba(0,0,0,0.6), 0 1px 2px rgba(0,0,0,0.4);
}
/* MacroPanel cell — subtle hover so the grid feels alive without becoming
a giant heat-map of click affordances. Just a tiny background lift. */
.macro-cell:hover {
background: var(--bg-sunk) !important;
}
.macro-panel {
padding: 0;
margin-bottom: 12px;
overflow: hidden;
}
.macro-panel-head {
padding: 20px 22px 18px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.macro-panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
}
.macro-panel-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--up);
display: inline-block;
}
.macro-panel-subtitle {
margin-top: 6px;
font-size: 12px;
line-height: 1.45;
color: var(--ink-3);
}
.macro-panel-meta {
text-align: right;
font-size: 11px;
line-height: 1.45;
color: var(--ink-4);
flex-shrink: 0;
}
.macro-section {
border-top: 1px solid var(--line);
}
.macro-section-title {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 22px 12px;
/* Was 10px / ink-3 / weight 700 — too quiet, users couldn't see the
section structure. Bumped to 12px / ink-2 / weight 800. Still uppercase
+ tracked so it reads as a label, not a heading that competes with the
metric values. */
font-size: 12px;
color: var(--ink-2);
letter-spacing: 0.10em;
text-transform: uppercase;
font-weight: 800;
background: var(--bg-sunk);
border-bottom: 1px solid var(--line);
}
/* Tiny dot anchor on the left — visual signal that "this is a section
header" without using a heavy colored bar. */
.macro-section-title::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--ink-3);
flex-shrink: 0;
}
.macro-grid {
display: grid;
gap: 14px;
padding: 14px;
}
.macro-grid.one {
grid-template-columns: minmax(0, 1fr);
}
.macro-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.macro-metric-card,
.macro-guide-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
padding: 18px;
min-width: 0;
min-height: 248px;
display: flex;
flex-direction: column;
}
.macro-metric-card {
box-shadow: var(--shadow-1);
}
.macro-metric-card.tone-up {
border-color: color-mix(in oklab, var(--up) 28%, var(--line));
}
.macro-metric-card.tone-down {
border-color: color-mix(in oklab, var(--down) 28%, var(--line));
}
/* Stat card layout — label STACKED ABOVE the big number, not side-by-side.
Previous side-by-side layout pushed the title to the far left and the
value to the far right with too much air between them on wide screens.
The eye now reads top→bottom: label → value → summary → chips → button. */
.macro-metric-head {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.macro-metric-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
/* Slightly larger + bolder so "AHR999" doesn't disappear next to the
big number below. Treated as a label, not a heading. */
font-size: 15px;
font-weight: 650;
color: var(--ink-2);
}
.macro-rank {
font-size: 11px;
color: var(--ink-4);
letter-spacing: 0.08em;
font-weight: 700;
flex-shrink: 0;
}
.macro-metric-value {
/* Same size as before but sits directly under the label now, so the
eye-distance between "AHR999" and "0.4663" is ~6px instead of the
full card width. */
font-size: 34px;
line-height: 1;
letter-spacing: -0.03em;
font-weight: 800;
color: var(--ink);
text-align: left;
flex-shrink: 0;
}
.macro-guide-title {
font-size: 20px;
line-height: 1.15;
letter-spacing: -0.02em;
font-weight: 700;
color: var(--ink);
}
.macro-summary {
margin-top: 12px;
font-size: 14px;
line-height: 1.55;
color: var(--ink-2);
min-height: 66px;
}
.macro-thresholds {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
/* THRESHOLD CHIPS — "you are here" treatment
─────────────────────────────────────────────────────────
Design goal: the chip that the CURRENT value falls into must be the
single most-eye-catching element in the cell. Everything else fades back.
Two states:
base (inactive) — flat, outlined, muted gray. Tone is suppressed
so a "down (red)" inactive chip doesn't visually
outshout an active "neutral" chip. Stripping
tone here is intentional.
.active — filled pill with the tone colour, larger, raised
on shadow, scale 1.05. The eye can't miss it.
Hover the row → all chips bump to 0.8 opacity so you can read the
other bands' rules without losing the active one. */
.macro-threshold-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 5px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
font-size: 11.5px;
line-height: 1.35;
color: var(--ink-4);
font-weight: 500;
opacity: 0.45;
transition: opacity 160ms, transform 180ms cubic-bezier(.2,.8,.2,1),
box-shadow 180ms, background 180ms, color 180ms;
}
.macro-thresholds:hover .macro-threshold-chip { opacity: 0.8; }
/* When INACTIVE, suppress tone colors. They'd compete with the active chip
for attention. Tone returns the moment the chip becomes .active below. */
.macro-threshold-chip:not(.active) {
color: var(--ink-4) !important;
background: transparent !important;
border-color: var(--line) !important;
}
/* ACTIVE — the band the current value falls into. */
.macro-threshold-chip.active {
opacity: 1;
font-weight: 700;
font-size: 12px;
padding: 6px 14px;
transform: scale(1.06);
letter-spacing: 0.01em;
}
.macro-threshold-chip.up.active {
background: var(--up);
color: #fff;
border-color: var(--up);
box-shadow:
0 6px 18px color-mix(in oklab, var(--up) 35%, transparent),
0 1px 0 rgba(255,255,255,0.25) inset;
}
.macro-threshold-chip.down.active {
background: var(--down);
color: #fff;
border-color: var(--down);
box-shadow:
0 6px 18px color-mix(in oklab, var(--down) 35%, transparent),
0 1px 0 rgba(255,255,255,0.25) inset;
}
.macro-threshold-chip.neutral.active {
/* Amber = "caution" signal — between safe and danger. Lets the user
know "you are here, no extreme yet" without the alarming red. */
background: var(--amber);
color: oklch(20% 0.04 75);
border-color: var(--amber);
box-shadow:
0 6px 18px color-mix(in oklab, var(--amber) 35%, transparent),
0 1px 0 rgba(255,255,255,0.4) inset;
}
/* Dark mode tweaks — the soft white-shimmer inset doesn't read on dark cards,
so swap to a subtle darker outline. */
html[data-theme="dark"] .macro-threshold-chip.up.active,
html[data-theme="dark"] .macro-threshold-chip.down.active,
html[data-theme="dark"] .macro-threshold-chip.neutral.active {
box-shadow:
0 8px 24px color-mix(in oklab, currentColor 30%, transparent),
0 0 0 1px rgba(0,0,0,0.25) inset;
}
/* Section variants — reference cards (rules-only, no live number) get a
muted treatment so users instantly see they're explanatory, not live.
We REPLACE the dot anchor with a 📐 emoji (overriding the .macro-section-
title::before above) and dim the text color. */
.macro-section.reference .macro-section-title {
color: var(--ink-3);
}
.macro-section.reference .macro-section-title::before {
content: '📐';
width: auto;
height: auto;
background: none;
border-radius: 0;
font-size: 13px;
line-height: 1;
filter: grayscale(0.3);
}
.macro-guide-card {
background: color-mix(in oklab, var(--bg-sunk) 60%, var(--surface));
border-style: dashed;
}
.macro-guide-card .macro-guide-title::before {
content: 'RULES ';
font-size: 9px;
font-weight: 800;
letter-spacing: 0.12em;
padding: 2px 6px;
border-radius: 4px;
background: color-mix(in oklab, var(--ink) 8%, transparent);
color: var(--ink-3);
margin-right: 8px;
vertical-align: middle;
}
/* Action row — single small button, aligned to the bottom-right of the card.
Was previously a full-width dual-button group; pinning a compact button to
the corner keeps the card's eye-flow on the value + thresholds, with the
"go look at history" affordance present but quiet. */
.macro-actions {
display: flex;
justify-content: flex-end;
margin-top: auto;
padding-top: 14px;
}
.macro-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
/* Compact natural width — fits "Open chart CoinGlass ↗" comfortably */
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: color-mix(in oklab, var(--ink) 4%, var(--surface));
color: var(--ink);
text-decoration: none;
transition: background 120ms, border-color 120ms, transform 120ms, box-shadow 120ms;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.4);
}
.macro-action-btn:hover {
background: color-mix(in oklab, var(--ink) 7%, var(--surface));
border-color: color-mix(in oklab, var(--ink) 12%, var(--line));
transform: translateY(-1px);
box-shadow: var(--shadow-1);
}
.macro-action-btn.chart {
background: color-mix(in oklab, var(--amber) 10%, var(--surface));
border-color: color-mix(in oklab, var(--amber) 18%, var(--line));
}
/* The kicker ("OPEN CHART" uppercase eyebrow) used to sit ABOVE the label
in a 2-row layout. With the new compact button it lives inline next to
the label as a tiny prefix tag — same words, less vertical space. */
.macro-action-copy {
display: inline-flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.macro-action-kicker {
font-size: 9.5px;
line-height: 1;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-4);
font-weight: 700;
}
.macro-action-label {
font-size: 13px;
line-height: 1;
color: var(--ink);
font-weight: 600;
white-space: nowrap;
}
.macro-action-arrow {
font-size: 13px;
line-height: 1;
color: var(--ink-3);
flex-shrink: 0;
}
.macro-disclosure {
margin-top: 14px;
border-top: 1px dashed var(--line);
padding-top: 12px;
}
.macro-disclosure summary {
list-style: none;
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--ink-2);
}
.macro-disclosure summary::-webkit-details-marker {
display: none;
}
.macro-disclosure-body {
margin-top: 8px;
font-size: 12px;
line-height: 1.55;
color: var(--ink-3);
}
.macro-composite {
border-top: 1px solid var(--line);
padding: 20px 22px 22px;
background: var(--bg-sunk);
}
.macro-composite-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.macro-composite-label {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-3);
font-weight: 700;
}
.macro-composite-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.macro-composite-value {
font-size: 32px;
line-height: 0.95;
font-weight: 800;
letter-spacing: -0.03em;
}
.macro-regime-pill {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
}
.macro-composite-track {
position: relative;
height: 12px;
border-radius: 999px;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.08);
}
.macro-composite-needle {
position: absolute;
top: -4px;
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid var(--ink);
background: var(--surface);
box-shadow: var(--shadow-2);
transition: left 0.4s cubic-bezier(.2,.8,.2,1);
/* React re-mounts this element via `key={composite_score}` whenever the
score changes, which re-triggers the pulse animation below — a brief
attention cue for users tracking how the macro tilt shifts day to day. */
animation: macro-needle-pulse 0.9s ease-out 1;
}
@keyframes macro-needle-pulse {
0% { transform: scale(0.85); box-shadow: 0 0 0 0 currentColor; }
40% { transform: scale(1.18); box-shadow: 0 0 0 8px color-mix(in oklab, currentColor 18%, transparent); }
100% { transform: scale(1); box-shadow: var(--shadow-2); }
}
.macro-composite-scale {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
font-size: 10px;
letter-spacing: 0.04em;
color: var(--ink-4);
text-transform: uppercase;
}
.infotip:hover .infotip-bubble,
.infotip:focus-visible .infotip-bubble {
opacity: 1;
transform: translateY(0);
}
/* Placement variants (default = top) */
.infotip-top .infotip-bubble {
bottom: calc(100% + 8px);
left: 50%; transform: translate(-50%, 2px);
}
.infotip-top:hover .infotip-bubble,
.infotip-top:focus-visible .infotip-bubble {
transform: translate(-50%, 0);
}
.infotip-bottom .infotip-bubble {
top: calc(100% + 8px);
left: 50%; transform: translate(-50%, -2px);
}
.infotip-bottom:hover .infotip-bubble,
.infotip-bottom:focus-visible .infotip-bubble {
transform: translate(-50%, 0);
}
.infotip-left .infotip-bubble {
right: calc(100% + 8px);
top: 50%; transform: translate(2px, -50%);
}
.infotip-left:hover .infotip-bubble,
.infotip-left:focus-visible .infotip-bubble {
transform: translate(0, -50%);
}
.infotip-right .infotip-bubble {
left: calc(100% + 8px);
top: 50%; transform: translate(-2px, -50%);
}
.infotip-right:hover .infotip-bubble,
.infotip-right:focus-visible .infotip-bubble {
transform: translate(0, -50%);
}
/* ============================================================
Cards + primitives
============================================================ */
@@ -1273,6 +1914,116 @@ html[data-theme="dark"] .ai-reasoning-card {
grid-template-columns: 1fr;
}
.macro-panel-head,
.macro-composite-head {
flex-direction: column;
align-items: flex-start;
}
.macro-panel-meta {
text-align: left;
}
.macro-grid.two {
grid-template-columns: 1fr;
}
.macro-grid {
padding: 12px;
gap: 12px;
}
.macro-metric-card,
.macro-guide-card {
padding: 16px;
min-height: 0;
}
.macro-metric-head {
/* Desktop is now also flex-direction: column. Just override the gap
for mobile to keep label-to-value spacing comfortable on small fonts. */
gap: 8px;
}
.macro-metric-value {
font-size: 30px;
}
.macro-guide-title {
font-size: 18px;
}
.macro-summary {
font-size: 13px;
line-height: 1.5;
min-height: 0;
}
.macro-threshold-chip {
font-size: 11.5px;
/* Bump tap target on mobile — iOS HIG asks for ≥44pt, this is the
inline-flex bubble so we go a bit smaller but still comfortable. */
min-height: 36px;
padding: 7px 12px;
}
.macro-threshold-chip.active {
font-size: 12px;
padding: 8px 14px;
/* Stronger scale on mobile — small screens lose subtle differences. */
transform: scale(1.08);
}
.macro-action-btn {
/* Stays compact on mobile too — bottom-right corner pill, not full-width. */
padding: 8px 12px;
}
/* Composite scale labels — "BEAR / NEUTRAL / BULL" letters crowd at narrow
widths. Shorten them. */
.macro-composite-scale {
font-size: 9.5px;
letter-spacing: 0.02em;
}
/* InfoTip — boost tap area and clamp bubble inside viewport */
.infotip {
/* Pad outside the visible icon to enlarge the hit zone without making
the icon itself look bloated. */
padding: 8px;
margin: -8px -2px -8px 4px;
}
.infotip-icon {
width: 18px;
height: 18px;
font-size: 11px;
}
.infotip-bubble {
/* On small viewports a 280px-wide bubble can hang off the edge when
the tip sits near the right margin. Clamp to viewport width minus
a 16px breathing room each side. */
max-width: calc(100vw - 32px) !important;
}
/* Touch-only browsers fire :active on tap. Combined with :hover, this
ensures bubble shows on first tap (not the doubled-tap that some
CSS-hover-only tooltips require). */
.infotip:active .infotip-bubble {
opacity: 1;
transform: none;
}
.macro-action-label {
font-size: 12px;
white-space: normal;
}
.macro-composite {
padding: 18px 16px 20px;
}
.macro-composite-right {
justify-content: flex-start;
}
.row.between {
align-items: flex-start;
}
@@ -1325,6 +2076,13 @@ html[data-theme="dark"] .ai-reasoning-card {
.modal, .dialog { width: calc(100vw - 24px) !important; max-width: calc(100vw - 24px) !important; }
}
/* Ultra-narrow screens — drop the regime descriptive words on the composite
scale so it doesn't wrap. Keep just the numeric anchors. The .word spans
inside each label are set in MacroPanel.tsx so this hide works cleanly. */
@media (max-width: 420px) {
.macro-composite-scale .word { display: none; }
}
@media (max-width: 380px) {
.nav { padding: 8px 12px; }
.page { padding: 16px 12px 48px; }
+8 -6
View File
@@ -241,7 +241,7 @@ function getCopy(locale: string): GlossaryCopy {
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.',
'UTXO is the fundamental accounting unit of the Bitcoin network. Trump Alphas live Macro Vibes module no longer relies directly on UTXO-spend behavior, but the concept still matters for interpreting classic on-chain research.',
},
{
term: 'Perpetual Future',
@@ -277,11 +277,12 @@ function getCopy(locale: string): GlossaryCopy {
}
}
export function generateMetadata({
params: { locale },
export async function generateMetadata({
params,
}: {
params: { locale: string }
}): Metadata {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const copy = getCopy(locale)
return {
@@ -319,7 +320,8 @@ const CATEGORY_COLOR: Record<string, string> = {
'技术术语': '#94a3b8',
}
export default function GlossaryPage({ params: { locale } }: { params: { locale: string } }) {
export default async function GlossaryPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
const copy = getCopy(locale)
const categories = Array.from(new Set(copy.terms.map(t => t.category)))
+7 -7
View File
@@ -9,6 +9,7 @@ import type {
} from '@/types'
import { getKolPosts, getKolPost, getKolDigest, getKolChanges, getKolDivergence } from '@/lib/api'
import { swrFetch } from '@/lib/cache'
import PageHint from '@/components/ui/PageHint'
/**
* KOL feed — independent module. Lists analyzed posts from tracked KOLs
@@ -802,16 +803,15 @@ export default function KolPage({
<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>
<PageHint count={`${posts.length} posts`}>
19 crypto KOLs' essays and podcasts ingested daily, AI extracts every
ticker call, then cross-checked against the same KOLs' on-chain
wallets. Catches "talks vs trades" when their wallet says the
opposite of their tweets.
</PageHint>
</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}
@@ -7,6 +7,9 @@ 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'
import InfoTip from '@/components/ui/InfoTip'
import PageHint from '@/components/ui/PageHint'
import MacroPanel from '@/components/btc/MacroPanel'
/**
* System 2 BTC bottom-reversal. Its own dedicated page.
@@ -17,15 +20,15 @@ const SENTIMENTS = ['all', 'bullish', 'bearish', 'neutral'] as const
type SentimentFilter = (typeof SENTIMENTS)[number]
type BtcTab = 'bottom' | 'funding'
interface BtcSignalPageProps {
interface MacroVibesPageProps {
initialPosts?: TrumpPost[] | null
initialFundingSnapshot?: FundingSnapshot | null
}
export default function BtcSignalPage({
export default function MacroVibesPage({
initialPosts = null,
initialFundingSnapshot = null,
}: BtcSignalPageProps) {
}: MacroVibesPageProps) {
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 ?? [])
@@ -70,24 +73,20 @@ export default function BtcSignalPage({
<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>
<h1 className="page-title">{isZh ? '宏观氛围' : 'Macro Vibes'}</h1>
{/* PageHint: brand tagline for the page same across both tabs so
the "what is this page" answer doesn't shift under the user when
they click between Bottom / Funding. Tab-specific notes live in
the section-hint card right below the tab bar. */}
<PageHint>
Macro vibes for crypto. Read what&apos;s about to happen before price prints it.
</PageHint>
</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,
}}>
@@ -107,20 +106,34 @@ export default function BtcSignalPage({
</button>
</div>
{/* Always-visible per-tab explanation. Was hidden behind a hover
tooltip on the tab button users couldn't tell what they were
about to look at until they explicitly hovered. */}
{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 className="section-hint">
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, 24 fires per cycle.
Holds up to 18 months with a trailing-stop exit no take-profit.
</div>
)}
{/* Macro indicator panel only relevant on the Macro Bottom tab where
users are reasoning about the broader risk regime. The Funding tab
has its own live funding panel below. */}
{tab === 'bottom' && <MacroPanel />}
{tab === 'funding' && (
<div className="section-hint">
Mean-reversion against crowded perp positioning. When 30-day cumulative
funding crosses <strong>±3%</strong> AND the most recent cycles start
cooling off, the scanner bets against the side that's been paying for
weeks. Hourly check.
</div>
)}
{/* Old plain-text per-tab description removed the same content
now lives in the section-hint block above the tab content. */}
{tab === 'funding' && (
<FundingPanel
isZh={isZh}
@@ -262,16 +275,19 @@ function FundingPanel({
<StatBox
label="Latest cycle"
value={`${snap.latest_rate_pct?.toFixed(4)}%`}
hint="The most recent funding payment, in % per cycle. Positive = longs pay shorts."
/>
<StatBox
label="Last 24h avg"
value={`${snap.last_24h_avg_pct?.toFixed(4)}%`}
hint="Average of the last 24 hours of funding cycles. Smooths out one-off prints."
/>
<StatBox
label="30d cumulative"
value={`${cum >= 0 ? '+' : ''}${cum.toFixed(2)}%`}
color={cumColor}
sub={`extreme @ ±${thr}%`}
hint="Sum of every funding payment over 30 days. Crosses ±3% = one side has been paying for weeks — overcrowded."
/>
<StatBox
label="Signal"
@@ -281,6 +297,7 @@ function FundingPanel({
? '⏳ Watching'
: '✓ Quiet'}
color={snap.signal_fired ? 'var(--down)' : extreme ? 'var(--amber, #f59e0b)' : 'var(--up)'}
hint="Quiet = normal range. Watching = extreme funding detected, waiting for mean-revert + price confirm. Fired = trade triggered."
/>
</div>
@@ -310,16 +327,22 @@ function FundingPanel({
)
}
function StatBox({ label, value, color, sub }: {
label: string; value: string; color?: string; sub?: string
function StatBox({ label, value, color, sub, hint }: {
label: string; value: string; color?: string; sub?: string; hint?: string
}) {
return (
<div style={{
padding: 10, background: 'var(--bg-sunk)', borderRadius: 6,
border: '1px solid var(--line)',
// The InfoTip bubble overflows the stat box; let it escape.
overflow: 'visible',
}}>
<div style={{ fontSize: 10, color: 'var(--ink-4)', textTransform: 'uppercase',
letterSpacing: '.06em', marginBottom: 4 }}>{label}</div>
letterSpacing: '.06em', marginBottom: 4,
display: 'flex', alignItems: 'center' }}>
{label}
{hint && <InfoTip text={hint} placement="top" width={220} />}
</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>}
+76
View File
@@ -0,0 +1,76 @@
import { getFundingSnapshot, getPosts } from '@/lib/api'
import type { Metadata } from 'next'
import MacroVibesPageClient from './MacroVibesPageClient'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
export const revalidate = 30
interface MacroVibesPageProps {
params: Promise<{ locale: string }>
}
export async function generateMetadata({
params,
}: MacroVibesPageProps): 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 ? '宏观氛围 — 加密市场宏观读数' : 'Macro Vibes — read crypto macro before price prints',
description: isZh
? 'Trump Alpha 的宏观氛围面板:AHR999、Fear & Greed、BTC 主导率、ETH/BTC、稳定币供应、ETF 资金流、永续 OI、Altcoin Season Index 八大公开宏观指标合一,外加 BTC 周期底部 + 资金费率反转两条触发器。'
: 'Macro vibes for crypto. Eight public macro signals in one view — AHR999, Fear & Greed, BTC dominance, ETH/BTC, stablecoin supply, ETF flows, perp OI, Altcoin Season Index — plus BTC bottom and funding-rate triggers.',
keywords: isZh
? [
'宏观氛围',
'加密宏观',
'AHR999',
'Fear and Greed',
'BTC 主导率',
'ETH/BTC',
'ETF 资金流',
'稳定币供应',
'资金费率反转',
'Altcoin Season Index',
]
: [
'crypto macro',
'macro vibes',
'AHR999',
'Fear and Greed Index',
'BTC dominance',
'ETH/BTC ratio',
'stablecoin supply',
'Bitcoin ETF flow',
'funding rate reversal',
'Altcoin Season Index',
],
openGraph: {
title: isZh ? '宏观氛围 | Trump Alpha' : 'Macro Vibes | Trump Alpha',
description: isZh
? '加密宏观一屏看完:八大宏观指标 + BTC 周期底部 + 资金费率反转触发器。'
: 'Macro vibes for crypto. Read what is about to happen before price prints it.',
},
alternates: {
canonical: `${siteUrl}/${locale}/macro`,
languages: {
en: `${siteUrl}/en/macro`,
'x-default': `${siteUrl}/en/macro`,
},
},
}
}
export default async function MacroVibesPage() {
const [posts, fundingSnapshot] = await Promise.all([
getPosts(500, 1).catch(() => null),
getFundingSnapshot().catch(() => null),
])
return (
<MacroVibesPageClient
initialPosts={posts}
initialFundingSnapshot={fundingSnapshot}
/>
)
}
+7 -5
View File
@@ -367,11 +367,12 @@ SHORT_INTENT + SHORT_ACTION => ALIGNMENT`,
}
}
export function generateMetadata({
params: { locale },
export async function generateMetadata({
params,
}: {
params: { locale: string }
}): Metadata {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const copy = getCopy(locale)
return {
@@ -431,7 +432,8 @@ function Formula({ children }: { children: React.ReactNode }) {
)
}
export default function MethodologyPage({ params: { locale } }: { params: { locale: string } }) {
export default async function MethodologyPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
const copy = getCopy(locale)
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
+2 -2
View File
@@ -18,8 +18,8 @@ export async function generateMetadata({ params }: OverviewPageProps): Promise<M
? '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.'
? '实时查看 Trump Truth Social 信号、Macro Vibes 宏观氛围、KOL 观点和 talks-vs-trades 分歧,一页掌握核心加密信号。'
: 'Track Trump Truth Social signals, Macro Vibes (crypto macro regime), KOL calls, and talks-vs-trades divergence in one live crypto dashboard.'
const path = `${siteUrl}/${locale}`
return {
+7 -3
View File
@@ -4,11 +4,15 @@ import { redirect } from 'next/navigation'
* Legacy route. The old "Signals" hub was replaced by two top-level nav
* tabs (Trump / BTC). Anything still pointing here (old links, sitemap,
* bookmarks) lands on the Trump system page.
*
* NOTE: Next.js 15+ made `params` async. Without `await` we'd interpolate
* the still-pending Promise as `undefined` and ship users to /undefined/trump.
*/
export default function LegacySignalsRedirect({
export default async function LegacySignalsRedirect({
params,
}: {
params: { locale: string }
params: Promise<{ locale: string }>
}) {
redirect(`/${params.locale}/trump`)
const { locale } = await params
redirect(`/${locale}/trump`)
}
+2 -1
View File
@@ -1,7 +1,8 @@
import Link from 'next/link'
import { getLocale } from 'next-intl/server'
export default async function PrivacyPage({ params: { locale } }: { params: { locale: string } }) {
export default async function PrivacyPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
const intlLocale = await getLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
+12 -1
View File
@@ -7,6 +7,7 @@ import { useLocale } from 'next-intl'
import { useAccount } from 'wagmi'
import BotConfigPanel from '@/components/trades/BotConfigPanel'
import TelegramCard from '@/components/telegram/TelegramCard'
import PageHint from '@/components/ui/PageHint'
/**
* Settings page — the home for all configuration.
@@ -30,7 +31,17 @@ export default function SettingsClient() {
const href = (path: string) => `/${locale}${path}`
return (
<div>
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '设置' : 'Settings'}</h1>
<PageHint>
Everything that controls your account subscription, Hyperliquid
API key, bot risk parameters, and Telegram alerts.
</PageHint>
</div>
</div>
{/* Account card — quick "who am I logged in as" */}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
+5 -1
View File
@@ -11,6 +11,7 @@ import { useDashboardStore } from '@/store/dashboard'
import { getCachedViewEnvelope, getOrCreateViewEnvelope } from '@/lib/signedRequest'
import TradeTable from '@/components/trades/TradeTable'
import OpenPositions from '@/components/positions/OpenPositions'
import PageHint from '@/components/ui/PageHint'
export default function TradesPageClient() {
const intlLocale = useLocale()
@@ -71,7 +72,10 @@ export default function TradesPageClient() {
<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>
<PageHint>
What the bot actually did with your money currently-open positions
on top, closed-trade history with realized P&amp;L below.
</PageHint>
</div>
</div>
+19 -14
View File
@@ -7,6 +7,8 @@ import { getPosts } from '@/lib/api'
import { swrFetch } from '@/lib/cache'
import PostRow from '@/components/dashboard/PostCards'
import SystemControl from '@/components/signals/SystemControl'
import PageHint from '@/components/ui/PageHint'
import InfoTip from '@/components/ui/InfoTip'
/**
* System 1 — Trump (event-driven scalp). Its own dedicated page.
@@ -80,17 +82,14 @@ export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPage
<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>
<PageHint count={`${sigCounts.actionable} actionable / ${trumpPosts.length} posts`}>
Watches Trump's Truth Social posts in real time, AI-scores each one,
and only fires a trade when the conviction is high enough to move price.
</PageHint>
</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={{
@@ -99,24 +98,30 @@ export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPage
}}>
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{([
{ key: 'all' },
{ key: 'actionable' },
{ key: 'buy' },
{ key: 'short' },
{ key: 'all', hint: 'Every post the scraper has captured.' },
{ key: 'actionable', hint: 'Only posts the AI flagged as BUY or SHORT.' },
{ key: 'buy', hint: 'Posts where the AI verdict is long BTC.' },
{ key: 'short', hint: 'Posts where the AI verdict is short BTC.' },
] 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 ? '🔥 ' : ''}
{f.key === 'actionable' ? '🔥 ' : ''}
{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' }}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--ink-4)', marginRight: 4 }}>
Sentiment
<InfoTip
text="Filters by the post's overall tone. Sentiment trade signal a bearish post can still be a BUY if the market reads it as priced-in."
placement="left"
/>
</span>
{SENTIMENTS.map(f => (
<button
key={f}
+1 -2
View File
@@ -27,7 +27,7 @@ export async function generateMetadata({
'特朗普发帖行情',
'Trump 自动交易',
'Hyperliquid 机器人',
'Trump BTC 信号',
'Trump 加密信号',
]
: [
'Trump crypto signal',
@@ -36,7 +36,6 @@ export async function generateMetadata({
'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',
+6 -15
View File
@@ -1,5 +1,4 @@
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 './[locale]/globals.css'
@@ -9,16 +8,6 @@ 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 geistSans = Geist({
subsets: ['latin'],
variable: '--font-geist-sans',
})
const geistMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-geist-mono',
})
export const metadata: Metadata = {
metadataBase: siteUrl ? new URL(siteUrl) : new URL('https://trumpsignal.com'),
title: {
@@ -32,6 +21,8 @@ export const metadata: Metadata = {
'TrumpSignal',
'crypto signals',
'crypto alpha dashboard',
'Macro Vibes',
'crypto macro dashboard',
'BTC bottom reversal',
'Bitcoin macro bottom',
'AHR999',
@@ -164,10 +155,10 @@ const jsonLd = {
},
{
'@type': 'Question',
name: 'What is the BTC bottom reversal signal?',
name: 'What is Macro Vibes?',
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.',
text: 'Macro Vibes is the macro-regime dashboard for crypto. It surfaces eight free, public macro signals in one view — AHR999, Fear & Greed, BTC dominance, ETH/BTC ratio, total stablecoin supply, US spot Bitcoin ETF daily net flow, BTC perp open interest, and the Altcoin Season Index — plus a long-only BTC bottom-reversal trigger (2-of-3 confluence of AHR999, 200-week MA, and Pi Cycle Bottom) and a BTC perp funding-rate reversal trigger. One screen tells you whether the macro tape is bullish, bearish, or neutral.',
},
},
{
@@ -191,7 +182,7 @@ const jsonLd = {
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.',
text: 'Yes. All four signal dashboards (Trump, Macro Vibes, 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.',
},
},
{
@@ -247,7 +238,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<body>
{/* Umami analytics — loaded after interactive so it never blocks render */}
<Script
src="https://stats.bitnews.day/script.js"
+1 -1
View File
@@ -86,7 +86,7 @@ export default function OGImage() {
>
{[
{ label: 'Trump · Truth Social', color: '#f5a524' },
{ label: 'BTC Bottom Reversal', color: '#a78bfa' },
{ label: 'Macro Vibes', color: '#a78bfa' },
{ label: 'KOL Signal', color: '#22c55e' },
{ label: 'Talks vs Trades', color: '#ef4444' },
].map((p) => (
+3 -3
View File
@@ -326,11 +326,11 @@ export default function LandingPage() {
/>
<PillarCard
tag="Daily · macro"
title="BTC Bottom Reversal"
title="Macro Vibes"
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"
href="/en/macro"
/>
<PillarCard
tag="Daily · narrative"
@@ -354,7 +354,7 @@ export default function LandingPage() {
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"
href="/en/macro"
/>
<PillarCard
tag="On demand · execution"
+1 -1
View File
@@ -21,7 +21,7 @@ const routes: Array<{
}> = [
{ path: '', priority: 1.0, freq: 'hourly' },
{ path: '/trump', priority: 0.9, freq: 'hourly' },
{ path: '/btc', priority: 0.9, freq: 'daily' },
{ path: '/macro', 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' },
+631
View File
@@ -0,0 +1,631 @@
'use client'
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { getMacroSnapshot, type MacroSnapshot } from '@/lib/api'
import { swrFetch } from '@/lib/cache'
import InfoTip from '@/components/ui/InfoTip'
function fmtUsd(n: number | null | undefined, { compact = false } = {}): string {
if (n == null || isNaN(n)) return '—'
const abs = Math.abs(n)
if (compact) {
if (abs >= 1e9) return `${(n / 1e9).toFixed(2)}B`
if (abs >= 1e6) return `${(n / 1e6).toFixed(1)}M`
if (abs >= 1e3) return `${(n / 1e3).toFixed(0)}K`
return n.toFixed(0)
}
return n.toLocaleString('en-US', { maximumFractionDigits: 2 })
}
function fmtSignedUsd(n: number | null | undefined): string {
if (n == null || isNaN(n)) return '—'
const compact = fmtUsd(Math.abs(n), { compact: true })
return (n >= 0 ? '+$' : '-$') + compact
}
function fmtPct(n: number | null | undefined, digits = 2): string {
if (n == null || isNaN(n)) return '—'
return n.toFixed(digits) + '%'
}
type Tone = 'up' | 'down' | 'neutral'
function toneAhr(v: number | null): Tone {
if (v == null) return 'neutral'
if (v < 0.45) return 'up'
if (v > 1.2) return 'down'
return 'neutral'
}
function toneAltseason(v: number | null): Tone {
if (v == null) return 'neutral'
if (v >= 75) return 'up'
if (v <= 25) return 'down'
return 'neutral'
}
function toneFearGreed(v: number | null): Tone {
if (v == null) return 'neutral'
if (v <= 25) return 'up'
if (v >= 75) return 'down'
return 'neutral'
}
function toneEtfFlow(v: number | null): Tone {
if (v == null) return 'neutral'
if (v > 50_000_000) return 'up'
if (v < -50_000_000) return 'down'
return 'neutral'
}
function toneBtcDominance(v: number | null): Tone {
if (v == null) return 'neutral'
if (v >= 60 || v <= 40) return 'down'
return 'neutral'
}
function toneEthBtc(v: number | null): Tone {
if (v == null) return 'neutral'
if (v >= 0.04) return 'up'
if (v <= 0.025) return 'down'
return 'neutral'
}
const REGIME_COLOR: Record<string, string> = {
BULL: 'var(--up)',
BULLISH: 'var(--up)',
NEUTRAL: 'var(--ink-3)',
BEARISH: 'var(--down)',
BEAR: 'var(--down)',
}
/**
* Single "Open chart" button per card. Was two (Data source + Chart) — users
* couldn't tell which to click and the dual-link group split attention.
* Defaults to a CoinGlass page; when CoinGlass doesn't host the indicator
* (Pi Cycle Bottom, Stablecoin Supply), we fall back to the canonical site.
*/
function SourceButton({
href,
children,
}: {
href: string
children: ReactNode
}) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="macro-action-btn chart"
>
<span className="macro-action-copy">
<span className="macro-action-kicker">Open chart</span>
<span className="macro-action-label">{children}</span>
</span>
<span className="macro-action-arrow" aria-hidden="true"></span>
</a>
)
}
function ThresholdChip({
tone = 'neutral',
active = false,
children,
}: {
tone?: Tone
active?: boolean
children: ReactNode
}) {
const cls = `macro-threshold-chip ${tone}${active ? ' active' : ''}`
return <span className={cls}>{children}</span>
}
function MetricCard({
rank,
label,
value,
tone = 'neutral',
hint,
summary,
thresholds,
activeIndex,
chartHref,
chartLabel = 'CoinGlass',
}: {
rank: number
label: string
value: string
tone?: Tone
hint: string
summary: string
thresholds: Array<{ label: string; tone?: Tone }>
/** Index into `thresholds` of the band the current value falls into.
* Renders that chip with the .active class — full opacity + highlight. */
activeIndex?: number
/** URL of the canonical chart page (usually CoinGlass). One button only —
* the dual "Data source + Chart" pattern confused users about which to click. */
chartHref: string
chartLabel?: string
}) {
return (
<article className={`macro-metric-card tone-${tone}`}>
<div className="macro-metric-head">
<div className="macro-metric-title">
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
<span>{label}</span>
<InfoTip text={hint} placement="top" width={280} />
</div>
<div className="macro-metric-value">{value}</div>
</div>
<div className="macro-summary">{summary}</div>
<div className="macro-thresholds">
{thresholds.map((item, i) => (
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
{item.label}
</ThresholdChip>
))}
</div>
<div className="macro-actions">
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
</div>
</article>
)
}
function GuideCard({
title,
summary,
thresholds,
chartHref,
chartLabel = 'CoinGlass',
}: {
title: string
summary: string
thresholds: Array<{ label: string; tone?: Tone }>
chartHref: string
chartLabel?: string
}) {
return (
<article className="macro-guide-card">
<div className="macro-guide-title">{title}</div>
<div className="macro-summary">{summary}</div>
<div className="macro-thresholds">
{thresholds.map((item) => (
<ThresholdChip key={item.label} tone={item.tone}>
{item.label}
</ThresholdChip>
))}
</div>
<div className="macro-actions">
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
</div>
</article>
)
}
function Section({
title,
children,
variant,
}: {
title: string
children: ReactNode
/** "reference" tones the section down + adds a 📐 icon so users see at a
* glance that the cards inside are explainers, not live data. */
variant?: 'reference'
}) {
return (
<section className={`macro-section${variant ? ' ' + variant : ''}`}>
<div className="macro-section-title">{title}</div>
{children}
</section>
)
}
export default function MacroPanel() {
const [snap, setSnap] = useState<MacroSnapshot | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
let alive = true
function load() {
swrFetch(
'macro-snapshot',
10 * 60_000,
() => getMacroSnapshot(),
(fresh) => { if (alive) setSnap(fresh) },
)
.then((s) => { if (alive) { setSnap(s); setErr('') } })
.catch((e) => { if (alive) setErr(e instanceof Error ? e.message : 'load failed') })
}
load()
const id = setInterval(load, 10 * 60_000)
return () => { alive = false; clearInterval(id) }
}, [])
if (err) {
return (
<div className="card" style={{ padding: 14, fontSize: 12, color: 'var(--down)' }}>
Couldn&apos;t load macro indicators {err}
</div>
)
}
if (!snap) {
return (
<div className="skeleton-card">
<div className="skeleton sk-line sk-w-3q" />
<div className="skeleton sk-line sk-w-half" />
<div className="skeleton sk-line sk-w-full" />
</div>
)
}
if (!snap.ok || !snap.indicators) {
return (
<div className="card" style={{ padding: 14, fontSize: 12, color: 'var(--ink-3)' }}>
Macro snapshot not available yet {snap.error ?? 'cron has not run'}.
</div>
)
}
const ind = snap.indicators
const ahrSay =
ind.ahr999 == null ? 'No data yet.'
: ind.ahr999 < 0.45 ? 'Deep-value zone. Historically this is where macro accumulation starts to make sense.'
: ind.ahr999 > 1.2 ? 'Expensive regime. This is no longer a bottom setup.'
: ind.ahr999 < 0.75 ? 'Reasonable value. Closer to cheap than to overheated.'
: 'Neutral-to-rich. No strong valuation edge from this metric alone.'
const altSay =
ind.altcoin_season_index == null ? 'No data yet.'
: ind.altcoin_season_index >= 75 ? 'Altseason conditions. Broad risk appetite is favoring alts over BTC.'
: ind.altcoin_season_index >= 60 ? 'Alt strength is building, but it is not a full altseason yet.'
: ind.altcoin_season_index >= 40 ? 'Mixed tape. Neither BTC nor alts have a decisive lead.'
: ind.altcoin_season_index >= 25 ? 'BTC is leading. This is a more defensive market posture.'
: 'Bitcoin season. Alts are broadly lagging BTC.'
const fgSay =
ind.fear_greed == null ? 'No data yet.'
: ind.fear_greed <= 20 ? 'Extreme fear. Contrarian bottom-hunting conditions.'
: ind.fear_greed <= 40 ? 'Fear is present, but not yet a full washout.'
: ind.fear_greed <= 60 ? 'Neutral sentiment. Little edge from crowd psychology here.'
: ind.fear_greed <= 80 ? 'Greed is building. Be selective about adding exposure.'
: 'Extreme greed. Historically a poor place to chase upside.'
const domSay =
ind.btc_dominance_pct == null ? 'No data yet.'
: ind.btc_dominance_pct >= 65 ? 'Capital is crowding back into BTC. That usually reads as caution.'
: ind.btc_dominance_pct >= 55 ? 'BTC still leads the market. Risk appetite is present, but selective.'
: ind.btc_dominance_pct >= 45 ? 'Balanced regime. No strong breadth message from dominance.'
: ind.btc_dominance_pct >= 35 ? 'Alt participation is improving. Risk appetite is broadening.'
: 'Dominance is very low. That often shows late-cycle froth.'
const ebrSay =
ind.eth_btc_ratio == null ? 'No data yet.'
: ind.eth_btc_ratio >= 0.05 ? 'ETH leadership is strong. This usually maps to a cleaner risk-on regime.'
: ind.eth_btc_ratio >= 0.04 ? 'ETH is waking up versus BTC. Useful confirmation for broader crypto risk appetite.'
: ind.eth_btc_ratio >= 0.03 ? 'Range-bound. ETH/BTC is not giving a decisive leadership signal.'
: ind.eth_btc_ratio >= 0.025 ? 'ETH is lagging, which usually means BTC remains the safer bid.'
: 'ETH is weak versus BTC. This is a defensive backdrop.'
const stabSay = ind.stablecoin_supply_usd == null
? 'No data yet.'
: 'This is a liquidity gauge, not a hard trigger. Watch the direction of supply over time.'
const etfSay = ind.etf_flow_net_usd_1d == null ? 'No data yet.' : (() => {
const v = ind.etf_flow_net_usd_1d ?? 0
if (v >= 500_000_000) return 'Huge ETF inflow day. Institutions are pressing the bid.'
if (v >= 200_000_000) return 'Strong inflow day. Institutional demand is clearly supportive.'
if (v >= 50_000_000) return 'Positive day, but not a major outlier.'
if (v > -50_000_000) return 'Roughly flat. No strong institutional message.'
if (v > -200_000_000) return 'Moderate outflow day. Some distribution, but not panic.'
if (v > -500_000_000) return 'Heavy outflows. Institutions are leaning defensive.'
return 'Very large outflows. This is a meaningful risk-off signal.'
})()
const oiSay = ind.btc_open_interest_usd == null
? 'No data yet.'
: 'Use this with price, not by itself. Rising OI confirms commitment; falling OI shows deleveraging.'
// ── Active threshold band per indicator ─────────────────────────────────
// Each function returns the index into the indicator's `thresholds` array
// that the CURRENT value falls into. Renders that chip with the .active
// CSS class so it stands out from the muted siblings.
// Indexes MUST stay in lock-step with the threshold array order below.
const ahrActive = ind.ahr999 == null ? -1
: ind.ahr999 < 0.45 ? 0
: ind.ahr999 < 0.75 ? 1
: ind.ahr999 <= 1.2 ? 2
: 3
const altActive = ind.altcoin_season_index == null ? -1
: ind.altcoin_season_index < 25 ? 0
: ind.altcoin_season_index < 60 ? 1
: ind.altcoin_season_index < 75 ? 2
: 3
const fgActive = ind.fear_greed == null ? -1
: ind.fear_greed < 25 ? 0
: ind.fear_greed < 45 ? 1
: ind.fear_greed < 60 ? 2
: ind.fear_greed < 75 ? 3
: 4
const domActive = ind.btc_dominance_pct == null ? -1
: ind.btc_dominance_pct < 45 ? 0
: ind.btc_dominance_pct < 55 ? 1
: ind.btc_dominance_pct < 65 ? 2
: 3
const ebrActive = ind.eth_btc_ratio == null ? -1
: ind.eth_btc_ratio < 0.025 ? 0
: ind.eth_btc_ratio < 0.04 ? 1
: 2
const etfActive = ind.etf_flow_net_usd_1d == null ? -1
: ind.etf_flow_net_usd_1d > 200_000_000 ? 0
: ind.etf_flow_net_usd_1d < -200_000_000 ? 2
: 1
const stabActive = ind.stablecoin_supply_usd == null ? -1 : 1 // we can't infer trend → land mid
const compositeColor =
snap.composite_score == null ? 'var(--ink-3)'
: snap.composite_score > 3 ? 'var(--up)'
: snap.composite_score < -3 ? 'var(--down)'
: 'var(--ink-3)'
const compositePct =
snap.composite_score == null ? 50
: Math.max(0, Math.min(100, (snap.composite_score + 100) / 2))
const trackGradient =
'linear-gradient(to right, ' +
'color-mix(in oklab, var(--down) 70%, transparent) 0%, ' +
'color-mix(in oklab, var(--down) 25%, transparent) 35%, ' +
'var(--bg-sunk) 50%, ' +
'color-mix(in oklab, var(--up) 25%, transparent) 65%, ' +
'color-mix(in oklab, var(--up) 70%, transparent) 100%)'
return (
<div className="card macro-panel">
<div className="macro-panel-head">
<div>
<div className="macro-panel-title">
<span className="macro-panel-dot" />
Macro indicators
</div>
<div className="macro-panel-subtitle">
Daily snapshot for BTC macro context. Designed for scanning first, details second.
</div>
</div>
<div className="macro-panel-meta">
<div>{snap.date}</div>
<div>free public sources</div>
</div>
</div>
<Section title="Valuation">
<div className="macro-grid one">
<MetricCard
rank={1}
label="AHR999"
value={ind.ahr999?.toFixed(4) ?? '—'}
tone={toneAhr(ind.ahr999)}
hint="BTC valuation index: price vs 200d MA × price vs fitted long-run growth curve. < 0.45 = buy/accumulation zone, 0.450.75 = fair value leaning cheap, 0.751.2 = neutral / no edge, > 1.2 = expensive."
summary={ahrSay}
activeIndex={ahrActive}
thresholds={[
{ label: '< 0.45 buy zone', tone: 'up' },
{ label: '0.450.75 fair value', tone: 'neutral' },
{ label: '0.751.2 no edge', tone: 'neutral' },
{ label: '> 1.2 expensive', tone: 'down' },
]}
chartHref="https://www.coinglass.com/en/pro/i/ahr999"
chartLabel="CoinGlass"
/>
</div>
</Section>
<Section title="Bottom trigger reference" variant="reference">
<div className="macro-grid two">
<GuideCard
title="200-week Moving Average"
summary="A structural support check. In this system, price near the 200WMA is one of the three bottom votes."
thresholds={[
{ label: '≤ 1.05× 200WMA = vote ON', tone: 'up' },
{ label: 'Below 200WMA = strongest value zone', tone: 'up' },
{ label: '1.05×–1.20× above = recovering', tone: 'neutral' },
{ label: 'Far above = no bottom vote', tone: 'down' },
]}
chartHref="https://www.coinglass.com/pro/i/200WMA"
chartLabel="CoinGlass"
/>
<GuideCard
title="Pi Cycle Bottom"
summary="A binary bottom condition. It is either confirmed or not confirmed; there is no graded middle reading in the live code."
thresholds={[
{ label: '150EMA ≤ 471SMA × 0.745 = vote ON', tone: 'up' },
{ label: '150EMA above line = not confirmed', tone: 'down' },
]}
// CoinGlass only hosts Pi Cycle TOP; for Pi Cycle BOTTOM the
// canonical chart lives on LookIntoBitcoin.
chartHref="https://www.lookintobitcoin.com/charts/pi-cycle-bottom-indicator/"
chartLabel="LookIntoBitcoin"
/>
</div>
</Section>
<Section title="Market structure">
<div className="macro-grid two">
<MetricCard
rank={2}
label="Altcoin Season"
value={ind.altcoin_season_index == null ? '—' : ind.altcoin_season_index.toFixed(0) + ' / 100'}
tone={toneAltseason(ind.altcoin_season_index)}
hint="% of top-50 alts that beat BTC over the last 30 days. < 25 = Bitcoin season, 2560 = mixed, 6075 = alt strength building, ≥ 75 = altseason."
summary={altSay}
activeIndex={altActive}
thresholds={[
{ label: '< 25 Bitcoin season', tone: 'down' },
{ label: '2560 mixed', tone: 'neutral' },
{ label: '6075 alt strength', tone: 'up' },
{ label: '≥ 75 altseason', tone: 'up' },
]}
chartHref="https://www.coinglass.com/en/pro/i/alt-coin-season"
chartLabel="CoinGlass"
/>
<MetricCard
rank={3}
label="BTC Dominance"
value={fmtPct(ind.btc_dominance_pct)}
tone={toneBtcDominance(ind.btc_dominance_pct)}
hint="BTC's share of total crypto market cap. < 45% = alt rotation, 4555% = balanced, 5565% = BTC-led / defensive, > 65% = flight to safety."
summary={domSay}
activeIndex={domActive}
thresholds={[
{ label: '< 45% alt rotation', tone: 'up' },
{ label: '4555% balanced', tone: 'neutral' },
{ label: '5565% BTC-led', tone: 'neutral' },
{ label: '> 65% safety trade', tone: 'down' },
]}
chartHref="https://www.coinglass.com/pro/i/bitcoin-dominance"
chartLabel="CoinGlass"
/>
<MetricCard
rank={4}
label="ETH / BTC"
value={ind.eth_btc_ratio == null ? '—' : ind.eth_btc_ratio.toFixed(5)}
tone={toneEthBtc(ind.eth_btc_ratio)}
hint="Relative strength of ETH vs BTC. < 0.025 = defensive / BTC-led, 0.0250.04 = range, > 0.04 = ETH and alts gaining leadership."
summary={ebrSay}
activeIndex={ebrActive}
thresholds={[
{ label: '< 0.025 defensive', tone: 'down' },
{ label: '0.0250.04 range', tone: 'neutral' },
{ label: '> 0.04 ETH leadership', tone: 'up' },
]}
chartHref="https://www.coinglass.com/currencies/ETHBTC"
chartLabel="CoinGlass"
/>
</div>
</Section>
<Section title="Sentiment & flows">
<div className="macro-grid two">
<MetricCard
rank={5}
label="Fear & Greed"
value={ind.fear_greed == null ? '—' : String(ind.fear_greed)}
tone={toneFearGreed(ind.fear_greed)}
hint="Sentiment composite from alternative.me. CONTRARIAN read — extreme fear is bullish, extreme greed is bearish."
summary={fgSay}
activeIndex={fgActive}
thresholds={[
{ label: '< 25 → BUY (extreme fear)', tone: 'up' },
{ label: '2545 fear', tone: 'neutral' },
{ label: '4560 neutral', tone: 'neutral' },
{ label: '6075 greed', tone: 'neutral' },
{ label: '> 75 → SELL (extreme greed)', tone: 'down' },
]}
chartHref="https://www.coinglass.com/pro/i/FearGreedIndex?s=09"
chartLabel="CoinGlass"
/>
<MetricCard
rank={6}
label="Stablecoin Supply"
value={ind.stablecoin_supply_usd == null ? '—' : '$' + fmtUsd(ind.stablecoin_supply_usd, { compact: true })}
summary={stabSay}
hint="Sum of all USDT + USDC + DAI + others in circulation. Rising = dry powder accumulating; falling = capital exiting crypto."
activeIndex={stabActive}
thresholds={[
{ label: 'Rising = liquidity building', tone: 'up' },
{ label: 'Flat = neutral', tone: 'neutral' },
{ label: 'Falling = capital leaving', tone: 'down' },
]}
// CoinGlass doesn't expose an all-issuer stablecoin supply view
// cleanly; DeFiLlama is the canonical aggregator we pull from.
chartHref="https://defillama.com/stablecoins"
chartLabel="DeFiLlama"
/>
<MetricCard
rank={7}
label="ETF Net Flow (1d)"
value={fmtSignedUsd(ind.etf_flow_net_usd_1d)}
tone={toneEtfFlow(ind.etf_flow_net_usd_1d)}
hint="Yesterday's total net flow into/out of US spot BTC ETFs. > +$200M = strong bid, $50M to +$50M = noise, < $200M = heavy outflow."
summary={etfSay}
activeIndex={etfActive}
thresholds={[
{ label: '> +$200M strong bid', tone: 'up' },
{ label: '±$50M noise band', tone: 'neutral' },
{ label: '< $200M heavy outflow', tone: 'down' },
]}
chartHref="https://www.coinglass.com/etf"
/>
<MetricCard
rank={8}
label="BTC Open Interest"
value={ind.btc_open_interest_usd == null ? '—' : '$' + fmtUsd(ind.btc_open_interest_usd, { compact: true })}
summary={oiSay}
hint="Total notional in BTC futures. No universal absolute threshold: use OI with price. Rising OI + rising price = trend confirmation; rising OI + falling price = trapped longs."
thresholds={[
{ label: 'OI up + price up = confirmation', tone: 'up' },
{ label: 'OI up + price down = stress', tone: 'down' },
{ label: 'OI down after flush = reset', tone: 'neutral' },
]}
chartHref="https://www.tradingview.com/symbols/BTC_OI/"
chartLabel="TradingView"
/>
</div>
</Section>
<div className="macro-composite">
<div className="macro-composite-head">
<div>
<div className="macro-composite-label">Composite read</div>
<div className="macro-panel-subtitle">
Advisory blend of the metrics above. Useful for context, not for blind execution.
</div>
</div>
<div className="macro-composite-right">
<span className="macro-composite-value" style={{ color: compositeColor }}>
{snap.composite_score == null
? '—'
: (snap.composite_score > 0 ? '+' : '') + snap.composite_score.toFixed(1)}
</span>
{snap.regime_label ? (
<span
className="macro-regime-pill"
style={{
color: REGIME_COLOR[snap.regime_label] || 'var(--ink-3)',
borderColor: `color-mix(in oklab, ${REGIME_COLOR[snap.regime_label] || 'var(--ink-3)'} 32%, transparent)`,
background: `color-mix(in oklab, ${REGIME_COLOR[snap.regime_label] || 'var(--ink-3)'} 12%, transparent)`,
}}
>
{snap.regime_label}
</span>
) : null}
</div>
</div>
<div className="macro-composite-track" style={{ background: trackGradient }}>
{snap.composite_score != null ? (
<div
// Keying by score forces a remount → the @keyframes pulse on
// .macro-composite-needle re-fires every time the score moves.
key={snap.composite_score}
className="macro-composite-needle"
style={{
left: `calc(${compositePct}% - 10px)`,
borderColor: compositeColor,
color: compositeColor,
}}
/>
) : null}
</div>
<div className="macro-composite-scale">
{/* .word spans are hidden on screens ≤420px so the scale becomes
just "-100", "0", "+100" — fits comfortably on any phone. */}
<span>100 <span className="word">bear</span></span>
<span>0 <span className="word">neutral</span></span>
<span>+100 <span className="word">bull</span></span>
</div>
</div>
</div>
)
}
+1 -1
View File
@@ -87,7 +87,7 @@ export default function Navbar() {
const tabs = [
{ key: '/', label: t('tabs.overview') },
{ key: '/trump', label: t('tabs.trump') },
{ key: '/btc', label: t('tabs.btc') },
{ key: '/macro', label: t('tabs.vibes') },
{ key: '/kol', label: t('tabs.kol') },
{ key: '/trades', label: t('tabs.trades') },
{ key: '/analytics', label: t('tabs.analytics') },
+32 -17
View File
@@ -26,7 +26,7 @@ import { confirmSign } from '@/components/wallet/SignConfirmSheet'
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)' },
btc: { idx: '②', name: 'Macro Vibes', accent: '#16a34a', soft: 'rgba(22,163,74,0.08)' },
} as const
const ROW: React.CSSProperties = {
@@ -122,22 +122,37 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
} 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>
)
}
// SSR / pre-hydration: render nothing to avoid hydration mismatch with
// wagmi state that resolves client-side.
if (!mounted) return null
// Wallet not connected: a previous version showed a giant full-width
// "Connect a wallet..." card here, which was redundant with the navbar's
// Connect button. The opposite extreme — rendering null — silently hid
// the existence of Auto-Trade for unconnected users. Compromise: a SLIM
// inline strip that simply announces the feature without pushing the
// page around. Once connected, the full control panel renders below.
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 style={{
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
padding: '10px 14px', marginBottom: 16,
background: 'var(--bg-sunk)',
border: '1px dashed var(--line)',
borderRadius: 8,
fontSize: 12, color: 'var(--ink-3)',
}}>
<span style={{
width: 6, height: 6, borderRadius: '50%',
background: 'var(--ink-4)', flexShrink: 0,
}} />
<span style={{ color: 'var(--ink-2)', fontWeight: 600 }}>
{s.name} Auto-Trade
</span>
<span>·</span>
<span style={{ color: 'var(--ink-4)' }}>
OFF connect wallet (top-right) to enable
</span>
</div>
)
}
@@ -156,7 +171,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
} 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.'
: 'Auto-Trade is OFF (global) — Trump AND Macro Vibes signals are shown in the feed but NOT traded.'
} else {
netOk = true
netMsg = isZh
@@ -210,8 +225,8 @@ export default function SystemControl({ system }: { system: '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.
Macro Vibes signal auto-opens a trade. Stop-loss / staged de-risk
always protect open positions either way.
</>
)}
</div>
+64
View File
@@ -0,0 +1,64 @@
'use client'
/**
* Inline "?"" hint icon with a hover tooltip.
*
* Use this for any UI element whose meaning isn't obvious from its label —
* stat boxes, tab names, jargon-y settings. Goal is the same as the (i)
* dots in Linear / Hyperliquid / Coinglass: small, ignorable, one hover
* away from a one-line plain-English explanation.
*
* Design rules:
* * `text` MUST be one short sentence — never paste long docs in here.
* If you need a paragraph, you're explaining the wrong thing.
* * The icon is intentionally subtle (12 px circle, ink-4 colour). It
* should disappear visually until the user actively scans for help.
* * The tooltip uses CSS hover only — no JS positioning, no portals.
* Trade-off: it's clipped by the nearest `overflow:hidden` ancestor.
* Pages that need tooltips inside a `<table>` cell should add
* `overflow: visible` to that row/td.
*
* Usage:
* <span>Latest cycle <InfoTip text="The single most recent funding payment, in %." /></span>
*
* <InfoTip
* text="Last 24h of cycles, averaged. Smooths the noise of one print."
* placement="bottom" // default 'top'
* />
*/
import type { CSSProperties } from 'react'
type Placement = 'top' | 'bottom' | 'left' | 'right'
interface Props {
text: string
placement?: Placement
/** Tip max-width in px. Default 240 — fits ~2 short lines. */
width?: number
/** Tweak the icon's vertical alignment for fussy contexts. */
iconStyle?: CSSProperties
}
export default function InfoTip({
text,
placement = 'top',
width = 240,
iconStyle,
}: Props) {
return (
<span
className={`infotip infotip-${placement}`}
// role=button is a slight overstatement but it lets screen-reader users
// know there's a thing to tab to. The actual content is in data-tip and
// also as a regular <span> child for non-CSS-tooltip fallback.
role="button"
tabIndex={0}
aria-label={text}
style={{ ['--tip-w' as string]: `${width}px`, ...iconStyle }}
>
<span aria-hidden="true" className="infotip-icon">?</span>
<span className="infotip-bubble" role="tooltip">{text}</span>
</span>
)
}
+29
View File
@@ -0,0 +1,29 @@
/**
* One-line "what is this page" tagline that sits right under the page title.
*
* Replaces ad-hoc <p className="page-sub"> spots that ended up as ghost-gray
* unreadable status lines ("Macro cycle bottom · 2 signals"). The new contract:
*
* * ALWAYS one short sentence — game-tutorial style. Tells the user the
* "what" in plain English, not the "how" (the how lives in InfoTip's).
* * Slightly bigger + higher contrast than page-sub. Meant to be read,
* not glanced past.
* * Optional `count` slot for the dim stat ("2 signals", "30 posts").
* Renders muted after a separator so the tagline itself stays clean.
*/
export default function PageHint({
children,
count,
}: {
children: React.ReactNode
count?: string
}) {
return (
<p className="page-hint">
{children}
{count && (
<span className="page-hint-count">{count}</span>
)}
</p>
)
}
+24
View File
@@ -466,6 +466,30 @@ export async function getFundingSnapshot(): Promise<FundingSnapshot> {
return fetchJson<FundingSnapshot>('/funding/snapshot')
}
// ── Macro snapshot (BTC page Macro Bottom tab) ──────────────────────────────
export interface MacroSnapshot {
ok: boolean
error?: string
date?: string
captured_at?: string
indicators?: {
ahr999: number | null
altcoin_season_index: number | null
fear_greed: number | null
fear_greed_label: string | null
btc_dominance_pct: number | null
eth_btc_ratio: number | null
stablecoin_supply_usd: number | null
etf_flow_net_usd_1d: number | null
btc_open_interest_usd: number | null
}
composite_score?: number | null
regime_label?: string | null
}
export async function getMacroSnapshot(): Promise<MacroSnapshot> {
return fetchJson<MacroSnapshot>('/macro/snapshot')
}
// ── Telegram alerts ─────────────────────────────────────────────────────────
export interface TelegramStatus {
configured: boolean
+1 -1
View File
@@ -4,7 +4,7 @@
"tabs": {
"overview": "Overview",
"trump": "Trump",
"btc": "BTC",
"vibes": "Vibes",
"kol": "KOL",
"trades": "Trades",
"analytics": "Analytics",
+1 -1
View File
@@ -4,7 +4,7 @@
"tabs": {
"overview": "总览",
"trump": "Trump",
"btc": "BTC",
"vibes": "Vibes",
"kol": "KOL",
"trades": "交易",
"analytics": "分析",
+2 -1
View File
@@ -9,7 +9,8 @@
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"lint": "eslint ."
"lint": "eslint .",
"verify": "npm run lint && npm run build"
},
"dependencies": {
"@rainbow-me/rainbowkit": "^2.1.3",
+5 -5
View File
@@ -1,20 +1,20 @@
# 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.
> AI-powered crypto intelligence platform. Four uncorrelated signal sources: Trump Truth Social sentiment, Macro Vibes (crypto macro regime dashboard), 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:
Trump Alpha aggregates four signal engines and one macro context view 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.
2. **Macro Vibes** — The crypto macro-regime dashboard. Surfaces eight free public macro signals in one view (AHR999, Fear & Greed Index, BTC dominance, ETH/BTC ratio, total stablecoin supply, US spot Bitcoin ETF daily net flow, BTC perpetual open interest, Altcoin Season Index) plus two trade triggers: a long-only BTC bottom-reversal (2-of-3 confluence across AHR999 < 0.45, price ≤ 200-week MA × 1.05, and Pi Cycle Bottom) and a BTC perpetual funding-rate reversal. Read what's about to happen before price prints it.
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.
5. **Funding Rate Reversal** — Lives inside Macro Vibes. Monitors BTC perpetual funding. Extreme positive or negative funding historically precedes mean-reversion; signals fire when rates cross multi-cycle thresholds AND the most recent cycles start cooling off.
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.
@@ -46,7 +46,7 @@ Optional: bring your own Hyperliquid account for auto-trading.
- Homepage / landing: https://trumpsignal.com
- Trump signal dashboard: https://trumpsignal.com/en/trump
- BTC bottom reversal: https://trumpsignal.com/en/btc
- Macro Vibes (crypto macro regime + BTC bottom + funding triggers): https://trumpsignal.com/en/macro
- 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