'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' 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. * 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 MacroVibesPageProps { initialPosts?: TrumpPost[] | null initialFundingSnapshot?: FundingSnapshot | null } export default function MacroVibesPage({ initialPosts = null, initialFundingSnapshot = null, }: 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(initialPosts ?? []) const [loading, setLoading] = useState(initialPosts === null) const [loadErr, setLoadErr] = useState('') const [sentFilter, setSentFilter] = useState('all') const [tab, setTab] = useState('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 = { all: isZh ? '全部' : 'All', bullish: isZh ? '看多' : 'Bullish', bearish: isZh ? '看空' : 'Bearish', neutral: isZh ? '中性' : 'Neutral', } return (

{isZh ? '宏观氛围' : 'Macro Vibes'}

{/* 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. */} Macro vibes for crypto. Read what's about to happen before price prints it.
Live
{/* 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' && (
Fires when ≥2 of 3 classic bottom signals agree:{' '} AHR999 < 0.45 · price ≤ 200-week MA {' '}· Pi Cycle Bottom. Long only, 2–4 fires per cycle. Holds up to 18 months with a trailing-stop exit — no take-profit.
)} {/* 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' && } {tab === 'funding' && (
Mean-reversion against crowded perp positioning. When 30-day cumulative funding crosses ±3% AND the most recent cycles start cooling off, the scanner bets against the side that's been paying for weeks. Hourly check.
)} {/* Old plain-text per-tab description removed — the same content now lives in the section-hint block above the tab content. */} {tab === 'funding' && ( )}
{SENTIMENTS.map(f => ( ))}
{loading && } {!loading && loadErr && (
⚠️ {`Couldn’t load signals — ${loadErr}`}
)} {!loading && !loadErr && filtered.length === 0 && (
{tab === 'bottom' ? 'No macro-bottom signals yet.' : 'No funding-reversal signals yet.'}
{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.'}
)} {!loading && filtered.length > 0 && (
{filtered.map(p => )}
)}
) } // ── Funding rate live panel ───────────────────────────────────────────────── function FundingPanel({ isZh, initialSnapshot = null, }: { isZh: boolean initialSnapshot?: FundingSnapshot | null }) { const [snap, setSnap] = useState(initialSnapshot) const [err, setErr] = useState('') useEffect(() => { let alive = true function load() { swrFetch( 'funding-snapshot', 5 * 60_000, // 5 min — funding cadence is 1–8h, 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 (
{`Funding snapshot failed — ${err}`}
) } if (!snap) { return (
) } if (!snap.ok) { return (
{`Funding data unavailable — ${snap.error || 'unknown error'}`}
) } // 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 (
BTC perp funding · live
{`${snap.cadence_hours}h cadence · ${snap.coverage_days}d coverage · Binance`}
= 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." />
{directionHint} {snap.debug?.reason && ( ({String(snap.debug.reason).replaceAll('_', ' ')}) )}
{snap.history && snap.history.length > 1 && ( )}
) } function StatBox({ label, value, color, sub, hint }: { label: string; value: string; color?: string; sub?: string; hint?: string }) { return (
{label} {hint && }
{value}
{sub &&
{sub}
}
) } // 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 (
{isZh ? '7 日资金费率(单周期 %)' : '7-day funding (per-cycle %)'} {isZh ? `危险区间:每周期 ±${perCycleThr.toFixed(3)}%` : `danger band: ±${perCycleThr.toFixed(3)}% / cycle`}
{/* danger zones — shaded above +thr and below -thr */} {/* zero line */} {/* positive / negative threshold lines */} {/* funding curve */} {/* current value dot */}
) } function BtcSkeleton() { return (
{Array.from({ length: 3 }).map((_, i) => (
))}
) }