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
-417
View File
@@ -1,417 +0,0 @@
'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
@@ -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}
/>
)
}