Files
trumpsignal-frontend/app/[locale]/macro/MacroVibesPageClient.tsx
T
k 33331efaee copy: tighten KOL + Macro Vibes page wording
- KOL PageHint: shorter, punchier ("call their bluff" variant)
- DigestTickerChip: fix plural bug — "1 KOL" vs "3 KOLs"
- WalletCheckWidget empty state: one sentence instead of two
- Macro Bottom section-hint: bullet-style trait list for scannability
- Macro Funding section-hint: "fades the crowded side" is cleaner than the previous rewording

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:05:24 +08:00

452 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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'
import PageHint from '@/components/ui/PageHint'
// MacroPanel is 631 lines with heavy indicator math — split it out.
const MacroPanel = dynamic(() => import('@/components/btc/MacroPanel'), {
ssr: false,
loading: () => <div style={{ height: 280, background: 'var(--bg-sunk)', borderRadius: 12, marginBottom: 20 }} />,
})
/**
* 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<TrumpPost[]>(initialPosts ?? [])
const [loading, setLoading] = useState(initialPosts === null)
const [loadErr, setLoadErr] = useState('')
const [sentFilter, setSentFilter] = useState<SentimentFilter>('all')
const [tab, setTab] = useState<BtcTab>('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 (24/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<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 ? '宏观氛围' : '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" />
<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>
{/* 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 className="section-hint">
Fires when <strong>2 of 3</strong> classic bottom signals agree:{' '}
<strong>AHR999 &lt; 0.45</strong> · <strong>price 200-week MA</strong> · <strong>Pi Cycle Bottom</strong>.
{' '}Long-only · 24 fires per cycle · trailing-stop exit · max hold ~18 months.
</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 recent cycles start cooling, the scanner fades the crowded side.
Checked hourly.
</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}
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 · ${
snap.venue ? snap.venue.charAt(0).toUpperCase() + snap.venue.slice(1) : '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 == null ? '—' : `${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 == null ? '—' : `${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"
value={snap.signal_fired
? '🚨 FIRED'
: extreme
? '⏳ 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>
<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, 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,
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>}
</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>
)
}