'use client' import { useState, useEffect, useMemo } from 'react' import dynamic from 'next/dynamic' 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' // MacroPanel is 631 lines with heavy indicator math — split it out. const MacroPanel = dynamic(() => import('@/components/btc/MacroPanel'), { ssr: false, loading: () =>
, }) /** * 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') // Tab-scoped post source. 'bottom' = MVRV/200WMA confluence, // 'funding' = funding-rate extreme reversal. const tabSource = tab === 'bottom' ? 'btc_bottom_reversal' : 'funding_reversal' useEffect(() => { // Fetch BY SOURCE, not the latest-500 global feed. These scanner signals // are rare (2–4/cycle, hourly) and were being pushed off the latest-500 // page by frequent Trump posts — making the Macro page look empty even // when signals existed. Server-side source filter guarantees we get them. setLoading(true) swrFetch( `posts-src-${tabSource}`, 3 * 60_000, () => getPosts(200, 1, tabSource), fresh => setPosts(fresh), ) .then(p => { setPosts(p); setLoadErr('') }) .catch(e => setLoadErr(e instanceof Error ? e.message : (isZh ? '信号加载失败' : 'Failed to load signals'))) .finally(() => setLoading(false)) }, [tabSource, isZh]) 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 (
{/* No "You open · bot manages" badge — the SystemControl strip right below says the same thing verbatim ("You open · bot manages exit"). */}

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

Live
{/* Tabs + inline description on the same row — avoids a separate "section hint" block that adds a third layer before the data. */}
{tab === 'bottom' ? <>≥2 of 3: AHR999 < 0.45 · 200w MA · Pi Cycle Bottom — long-only, rare. : <>Fades crowded perps when 30d cumulative funding crosses ±3% and cools. Hourly.}
{/* 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' && } {/* SignalMonitor (ETH/LINK Breakout Monitor) unmounted 2026-06-12: the backend scanner is disabled (services/funding_signal.py _enabled=False, operator-only toggle), so the panel only ever showed "Paused / No signals yet" — and breakout scanning is unrelated to funding reversal anyway. Component kept at components/dashboard/SignalMonitor.tsx; remount here if the scanner is ever re-enabled. */} {tab === 'funding' && ( )}
{SENTIMENTS.map(f => ( ))}
{loading && } {!loading && loadErr && (
⚠️ {`Couldn’t load signals — ${loadErr}`}
)} {!loading && !loadErr && filtered.length === 0 && (
{tab === 'bottom' ? 'No bottom signals yet.' : 'No funding signals yet.'}
{tab === 'bottom' ? 'Intentionally rare — fires only at genuine cycle bottoms.' : 'Check the live panel above for current funding 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 · ${ snap.venue ? snap.venue.charAt(0).toUpperCase() + snap.venue.slice(1) : '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('_', ' ')}) )}
{/* 7-day sparkline removed: the y-range was forced to include the ±threshold lines, so in normal regimes the curve rendered as a flat line on the zero axis with ~80% of the plot being empty danger-zone shading. The stat boxes above (latest / 24h avg / 30d cumulative / signal state) carry the signal. */}
) } function StatBox({ label, value, color, sub, hint }: { label: string; value: string; color?: string; sub?: string; hint?: string }) { return (
{label} {hint && }
{value}
{sub &&
{sub}
}
) } function BtcSkeleton() { return (
{Array.from({ length: 3 }).map((_, i) => (
))}
) }