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:
@@ -0,0 +1,440 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { getFundingSnapshot, getPosts } from '@/lib/api'
|
||||
import type { Metadata } from 'next'
|
||||
import MacroVibesPageClient from './MacroVibesPageClient'
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
|
||||
export const revalidate = 30
|
||||
|
||||
interface MacroVibesPageProps {
|
||||
params: Promise<{ locale: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: MacroVibesPageProps): 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 ? '宏观氛围 — 加密市场宏观读数' : 'Macro Vibes — read crypto macro before price prints',
|
||||
description: isZh
|
||||
? 'Trump Alpha 的宏观氛围面板:AHR999、Fear & Greed、BTC 主导率、ETH/BTC、稳定币供应、ETF 资金流、永续 OI、Altcoin Season Index 八大公开宏观指标合一,外加 BTC 周期底部 + 资金费率反转两条触发器。'
|
||||
: 'Macro vibes for crypto. Eight public macro signals in one view — AHR999, Fear & Greed, BTC dominance, ETH/BTC, stablecoin supply, ETF flows, perp OI, Altcoin Season Index — plus BTC bottom and funding-rate triggers.',
|
||||
keywords: isZh
|
||||
? [
|
||||
'宏观氛围',
|
||||
'加密宏观',
|
||||
'AHR999',
|
||||
'Fear and Greed',
|
||||
'BTC 主导率',
|
||||
'ETH/BTC',
|
||||
'ETF 资金流',
|
||||
'稳定币供应',
|
||||
'资金费率反转',
|
||||
'Altcoin Season Index',
|
||||
]
|
||||
: [
|
||||
'crypto macro',
|
||||
'macro vibes',
|
||||
'AHR999',
|
||||
'Fear and Greed Index',
|
||||
'BTC dominance',
|
||||
'ETH/BTC ratio',
|
||||
'stablecoin supply',
|
||||
'Bitcoin ETF flow',
|
||||
'funding rate reversal',
|
||||
'Altcoin Season Index',
|
||||
],
|
||||
openGraph: {
|
||||
title: isZh ? '宏观氛围 | Trump Alpha' : 'Macro Vibes | Trump Alpha',
|
||||
description: isZh
|
||||
? '加密宏观一屏看完:八大宏观指标 + BTC 周期底部 + 资金费率反转触发器。'
|
||||
: 'Macro vibes for crypto. Read what is about to happen before price prints it.',
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/${locale}/macro`,
|
||||
languages: {
|
||||
en: `${siteUrl}/en/macro`,
|
||||
'x-default': `${siteUrl}/en/macro`,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function MacroVibesPage() {
|
||||
const [posts, fundingSnapshot] = await Promise.all([
|
||||
getPosts(500, 1).catch(() => null),
|
||||
getFundingSnapshot().catch(() => null),
|
||||
])
|
||||
|
||||
return (
|
||||
<MacroVibesPageClient
|
||||
initialPosts={posts}
|
||||
initialFundingSnapshot={fundingSnapshot}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user