f34ae9eb00
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>
441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
'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<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 ? '宏观氛围' : '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'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 < 0.45</strong> · <strong>price ≤ 200-week MA</strong>
|
||
{' '}· <strong>Pi Cycle Bottom</strong>. Long only, 2–4 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}
|
||
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)' }}>
|
||
⚠️ {`Couldn’t 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 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 (
|
||
<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)}%`}
|
||
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"
|
||
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>
|
||
)
|
||
}
|