Files
trumpsignal-frontend/components/btc/MacroPanel.tsx
T
k f34ae9eb00 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>
2026-05-26 01:05:18 +08:00

632 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { getMacroSnapshot, type MacroSnapshot } from '@/lib/api'
import { swrFetch } from '@/lib/cache'
import InfoTip from '@/components/ui/InfoTip'
function fmtUsd(n: number | null | undefined, { compact = false } = {}): string {
if (n == null || isNaN(n)) return '—'
const abs = Math.abs(n)
if (compact) {
if (abs >= 1e9) return `${(n / 1e9).toFixed(2)}B`
if (abs >= 1e6) return `${(n / 1e6).toFixed(1)}M`
if (abs >= 1e3) return `${(n / 1e3).toFixed(0)}K`
return n.toFixed(0)
}
return n.toLocaleString('en-US', { maximumFractionDigits: 2 })
}
function fmtSignedUsd(n: number | null | undefined): string {
if (n == null || isNaN(n)) return '—'
const compact = fmtUsd(Math.abs(n), { compact: true })
return (n >= 0 ? '+$' : '-$') + compact
}
function fmtPct(n: number | null | undefined, digits = 2): string {
if (n == null || isNaN(n)) return '—'
return n.toFixed(digits) + '%'
}
type Tone = 'up' | 'down' | 'neutral'
function toneAhr(v: number | null): Tone {
if (v == null) return 'neutral'
if (v < 0.45) return 'up'
if (v > 1.2) return 'down'
return 'neutral'
}
function toneAltseason(v: number | null): Tone {
if (v == null) return 'neutral'
if (v >= 75) return 'up'
if (v <= 25) return 'down'
return 'neutral'
}
function toneFearGreed(v: number | null): Tone {
if (v == null) return 'neutral'
if (v <= 25) return 'up'
if (v >= 75) return 'down'
return 'neutral'
}
function toneEtfFlow(v: number | null): Tone {
if (v == null) return 'neutral'
if (v > 50_000_000) return 'up'
if (v < -50_000_000) return 'down'
return 'neutral'
}
function toneBtcDominance(v: number | null): Tone {
if (v == null) return 'neutral'
if (v >= 60 || v <= 40) return 'down'
return 'neutral'
}
function toneEthBtc(v: number | null): Tone {
if (v == null) return 'neutral'
if (v >= 0.04) return 'up'
if (v <= 0.025) return 'down'
return 'neutral'
}
const REGIME_COLOR: Record<string, string> = {
BULL: 'var(--up)',
BULLISH: 'var(--up)',
NEUTRAL: 'var(--ink-3)',
BEARISH: 'var(--down)',
BEAR: 'var(--down)',
}
/**
* Single "Open chart" button per card. Was two (Data source + Chart) — users
* couldn't tell which to click and the dual-link group split attention.
* Defaults to a CoinGlass page; when CoinGlass doesn't host the indicator
* (Pi Cycle Bottom, Stablecoin Supply), we fall back to the canonical site.
*/
function SourceButton({
href,
children,
}: {
href: string
children: ReactNode
}) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="macro-action-btn chart"
>
<span className="macro-action-copy">
<span className="macro-action-kicker">Open chart</span>
<span className="macro-action-label">{children}</span>
</span>
<span className="macro-action-arrow" aria-hidden="true"></span>
</a>
)
}
function ThresholdChip({
tone = 'neutral',
active = false,
children,
}: {
tone?: Tone
active?: boolean
children: ReactNode
}) {
const cls = `macro-threshold-chip ${tone}${active ? ' active' : ''}`
return <span className={cls}>{children}</span>
}
function MetricCard({
rank,
label,
value,
tone = 'neutral',
hint,
summary,
thresholds,
activeIndex,
chartHref,
chartLabel = 'CoinGlass',
}: {
rank: number
label: string
value: string
tone?: Tone
hint: string
summary: string
thresholds: Array<{ label: string; tone?: Tone }>
/** Index into `thresholds` of the band the current value falls into.
* Renders that chip with the .active class — full opacity + highlight. */
activeIndex?: number
/** URL of the canonical chart page (usually CoinGlass). One button only —
* the dual "Data source + Chart" pattern confused users about which to click. */
chartHref: string
chartLabel?: string
}) {
return (
<article className={`macro-metric-card tone-${tone}`}>
<div className="macro-metric-head">
<div className="macro-metric-title">
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
<span>{label}</span>
<InfoTip text={hint} placement="top" width={280} />
</div>
<div className="macro-metric-value">{value}</div>
</div>
<div className="macro-summary">{summary}</div>
<div className="macro-thresholds">
{thresholds.map((item, i) => (
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
{item.label}
</ThresholdChip>
))}
</div>
<div className="macro-actions">
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
</div>
</article>
)
}
function GuideCard({
title,
summary,
thresholds,
chartHref,
chartLabel = 'CoinGlass',
}: {
title: string
summary: string
thresholds: Array<{ label: string; tone?: Tone }>
chartHref: string
chartLabel?: string
}) {
return (
<article className="macro-guide-card">
<div className="macro-guide-title">{title}</div>
<div className="macro-summary">{summary}</div>
<div className="macro-thresholds">
{thresholds.map((item) => (
<ThresholdChip key={item.label} tone={item.tone}>
{item.label}
</ThresholdChip>
))}
</div>
<div className="macro-actions">
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
</div>
</article>
)
}
function Section({
title,
children,
variant,
}: {
title: string
children: ReactNode
/** "reference" tones the section down + adds a 📐 icon so users see at a
* glance that the cards inside are explainers, not live data. */
variant?: 'reference'
}) {
return (
<section className={`macro-section${variant ? ' ' + variant : ''}`}>
<div className="macro-section-title">{title}</div>
{children}
</section>
)
}
export default function MacroPanel() {
const [snap, setSnap] = useState<MacroSnapshot | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
let alive = true
function load() {
swrFetch(
'macro-snapshot',
10 * 60_000,
() => getMacroSnapshot(),
(fresh) => { if (alive) setSnap(fresh) },
)
.then((s) => { if (alive) { setSnap(s); setErr('') } })
.catch((e) => { if (alive) setErr(e instanceof Error ? e.message : 'load failed') })
}
load()
const id = setInterval(load, 10 * 60_000)
return () => { alive = false; clearInterval(id) }
}, [])
if (err) {
return (
<div className="card" style={{ padding: 14, fontSize: 12, color: 'var(--down)' }}>
Couldn&apos;t load macro indicators {err}
</div>
)
}
if (!snap) {
return (
<div className="skeleton-card">
<div className="skeleton sk-line sk-w-3q" />
<div className="skeleton sk-line sk-w-half" />
<div className="skeleton sk-line sk-w-full" />
</div>
)
}
if (!snap.ok || !snap.indicators) {
return (
<div className="card" style={{ padding: 14, fontSize: 12, color: 'var(--ink-3)' }}>
Macro snapshot not available yet {snap.error ?? 'cron has not run'}.
</div>
)
}
const ind = snap.indicators
const ahrSay =
ind.ahr999 == null ? 'No data yet.'
: ind.ahr999 < 0.45 ? 'Deep-value zone. Historically this is where macro accumulation starts to make sense.'
: ind.ahr999 > 1.2 ? 'Expensive regime. This is no longer a bottom setup.'
: ind.ahr999 < 0.75 ? 'Reasonable value. Closer to cheap than to overheated.'
: 'Neutral-to-rich. No strong valuation edge from this metric alone.'
const altSay =
ind.altcoin_season_index == null ? 'No data yet.'
: ind.altcoin_season_index >= 75 ? 'Altseason conditions. Broad risk appetite is favoring alts over BTC.'
: ind.altcoin_season_index >= 60 ? 'Alt strength is building, but it is not a full altseason yet.'
: ind.altcoin_season_index >= 40 ? 'Mixed tape. Neither BTC nor alts have a decisive lead.'
: ind.altcoin_season_index >= 25 ? 'BTC is leading. This is a more defensive market posture.'
: 'Bitcoin season. Alts are broadly lagging BTC.'
const fgSay =
ind.fear_greed == null ? 'No data yet.'
: ind.fear_greed <= 20 ? 'Extreme fear. Contrarian bottom-hunting conditions.'
: ind.fear_greed <= 40 ? 'Fear is present, but not yet a full washout.'
: ind.fear_greed <= 60 ? 'Neutral sentiment. Little edge from crowd psychology here.'
: ind.fear_greed <= 80 ? 'Greed is building. Be selective about adding exposure.'
: 'Extreme greed. Historically a poor place to chase upside.'
const domSay =
ind.btc_dominance_pct == null ? 'No data yet.'
: ind.btc_dominance_pct >= 65 ? 'Capital is crowding back into BTC. That usually reads as caution.'
: ind.btc_dominance_pct >= 55 ? 'BTC still leads the market. Risk appetite is present, but selective.'
: ind.btc_dominance_pct >= 45 ? 'Balanced regime. No strong breadth message from dominance.'
: ind.btc_dominance_pct >= 35 ? 'Alt participation is improving. Risk appetite is broadening.'
: 'Dominance is very low. That often shows late-cycle froth.'
const ebrSay =
ind.eth_btc_ratio == null ? 'No data yet.'
: ind.eth_btc_ratio >= 0.05 ? 'ETH leadership is strong. This usually maps to a cleaner risk-on regime.'
: ind.eth_btc_ratio >= 0.04 ? 'ETH is waking up versus BTC. Useful confirmation for broader crypto risk appetite.'
: ind.eth_btc_ratio >= 0.03 ? 'Range-bound. ETH/BTC is not giving a decisive leadership signal.'
: ind.eth_btc_ratio >= 0.025 ? 'ETH is lagging, which usually means BTC remains the safer bid.'
: 'ETH is weak versus BTC. This is a defensive backdrop.'
const stabSay = ind.stablecoin_supply_usd == null
? 'No data yet.'
: 'This is a liquidity gauge, not a hard trigger. Watch the direction of supply over time.'
const etfSay = ind.etf_flow_net_usd_1d == null ? 'No data yet.' : (() => {
const v = ind.etf_flow_net_usd_1d ?? 0
if (v >= 500_000_000) return 'Huge ETF inflow day. Institutions are pressing the bid.'
if (v >= 200_000_000) return 'Strong inflow day. Institutional demand is clearly supportive.'
if (v >= 50_000_000) return 'Positive day, but not a major outlier.'
if (v > -50_000_000) return 'Roughly flat. No strong institutional message.'
if (v > -200_000_000) return 'Moderate outflow day. Some distribution, but not panic.'
if (v > -500_000_000) return 'Heavy outflows. Institutions are leaning defensive.'
return 'Very large outflows. This is a meaningful risk-off signal.'
})()
const oiSay = ind.btc_open_interest_usd == null
? 'No data yet.'
: 'Use this with price, not by itself. Rising OI confirms commitment; falling OI shows deleveraging.'
// ── Active threshold band per indicator ─────────────────────────────────
// Each function returns the index into the indicator's `thresholds` array
// that the CURRENT value falls into. Renders that chip with the .active
// CSS class so it stands out from the muted siblings.
// Indexes MUST stay in lock-step with the threshold array order below.
const ahrActive = ind.ahr999 == null ? -1
: ind.ahr999 < 0.45 ? 0
: ind.ahr999 < 0.75 ? 1
: ind.ahr999 <= 1.2 ? 2
: 3
const altActive = ind.altcoin_season_index == null ? -1
: ind.altcoin_season_index < 25 ? 0
: ind.altcoin_season_index < 60 ? 1
: ind.altcoin_season_index < 75 ? 2
: 3
const fgActive = ind.fear_greed == null ? -1
: ind.fear_greed < 25 ? 0
: ind.fear_greed < 45 ? 1
: ind.fear_greed < 60 ? 2
: ind.fear_greed < 75 ? 3
: 4
const domActive = ind.btc_dominance_pct == null ? -1
: ind.btc_dominance_pct < 45 ? 0
: ind.btc_dominance_pct < 55 ? 1
: ind.btc_dominance_pct < 65 ? 2
: 3
const ebrActive = ind.eth_btc_ratio == null ? -1
: ind.eth_btc_ratio < 0.025 ? 0
: ind.eth_btc_ratio < 0.04 ? 1
: 2
const etfActive = ind.etf_flow_net_usd_1d == null ? -1
: ind.etf_flow_net_usd_1d > 200_000_000 ? 0
: ind.etf_flow_net_usd_1d < -200_000_000 ? 2
: 1
const stabActive = ind.stablecoin_supply_usd == null ? -1 : 1 // we can't infer trend → land mid
const compositeColor =
snap.composite_score == null ? 'var(--ink-3)'
: snap.composite_score > 3 ? 'var(--up)'
: snap.composite_score < -3 ? 'var(--down)'
: 'var(--ink-3)'
const compositePct =
snap.composite_score == null ? 50
: Math.max(0, Math.min(100, (snap.composite_score + 100) / 2))
const trackGradient =
'linear-gradient(to right, ' +
'color-mix(in oklab, var(--down) 70%, transparent) 0%, ' +
'color-mix(in oklab, var(--down) 25%, transparent) 35%, ' +
'var(--bg-sunk) 50%, ' +
'color-mix(in oklab, var(--up) 25%, transparent) 65%, ' +
'color-mix(in oklab, var(--up) 70%, transparent) 100%)'
return (
<div className="card macro-panel">
<div className="macro-panel-head">
<div>
<div className="macro-panel-title">
<span className="macro-panel-dot" />
Macro indicators
</div>
<div className="macro-panel-subtitle">
Daily snapshot for BTC macro context. Designed for scanning first, details second.
</div>
</div>
<div className="macro-panel-meta">
<div>{snap.date}</div>
<div>free public sources</div>
</div>
</div>
<Section title="Valuation">
<div className="macro-grid one">
<MetricCard
rank={1}
label="AHR999"
value={ind.ahr999?.toFixed(4) ?? '—'}
tone={toneAhr(ind.ahr999)}
hint="BTC valuation index: price vs 200d MA × price vs fitted long-run growth curve. < 0.45 = buy/accumulation zone, 0.450.75 = fair value leaning cheap, 0.751.2 = neutral / no edge, > 1.2 = expensive."
summary={ahrSay}
activeIndex={ahrActive}
thresholds={[
{ label: '< 0.45 buy zone', tone: 'up' },
{ label: '0.450.75 fair value', tone: 'neutral' },
{ label: '0.751.2 no edge', tone: 'neutral' },
{ label: '> 1.2 expensive', tone: 'down' },
]}
chartHref="https://www.coinglass.com/en/pro/i/ahr999"
chartLabel="CoinGlass"
/>
</div>
</Section>
<Section title="Bottom trigger reference" variant="reference">
<div className="macro-grid two">
<GuideCard
title="200-week Moving Average"
summary="A structural support check. In this system, price near the 200WMA is one of the three bottom votes."
thresholds={[
{ label: '≤ 1.05× 200WMA = vote ON', tone: 'up' },
{ label: 'Below 200WMA = strongest value zone', tone: 'up' },
{ label: '1.05×–1.20× above = recovering', tone: 'neutral' },
{ label: 'Far above = no bottom vote', tone: 'down' },
]}
chartHref="https://www.coinglass.com/pro/i/200WMA"
chartLabel="CoinGlass"
/>
<GuideCard
title="Pi Cycle Bottom"
summary="A binary bottom condition. It is either confirmed or not confirmed; there is no graded middle reading in the live code."
thresholds={[
{ label: '150EMA ≤ 471SMA × 0.745 = vote ON', tone: 'up' },
{ label: '150EMA above line = not confirmed', tone: 'down' },
]}
// CoinGlass only hosts Pi Cycle TOP; for Pi Cycle BOTTOM the
// canonical chart lives on LookIntoBitcoin.
chartHref="https://www.lookintobitcoin.com/charts/pi-cycle-bottom-indicator/"
chartLabel="LookIntoBitcoin"
/>
</div>
</Section>
<Section title="Market structure">
<div className="macro-grid two">
<MetricCard
rank={2}
label="Altcoin Season"
value={ind.altcoin_season_index == null ? '—' : ind.altcoin_season_index.toFixed(0) + ' / 100'}
tone={toneAltseason(ind.altcoin_season_index)}
hint="% of top-50 alts that beat BTC over the last 30 days. < 25 = Bitcoin season, 2560 = mixed, 6075 = alt strength building, ≥ 75 = altseason."
summary={altSay}
activeIndex={altActive}
thresholds={[
{ label: '< 25 Bitcoin season', tone: 'down' },
{ label: '2560 mixed', tone: 'neutral' },
{ label: '6075 alt strength', tone: 'up' },
{ label: '≥ 75 altseason', tone: 'up' },
]}
chartHref="https://www.coinglass.com/en/pro/i/alt-coin-season"
chartLabel="CoinGlass"
/>
<MetricCard
rank={3}
label="BTC Dominance"
value={fmtPct(ind.btc_dominance_pct)}
tone={toneBtcDominance(ind.btc_dominance_pct)}
hint="BTC's share of total crypto market cap. < 45% = alt rotation, 4555% = balanced, 5565% = BTC-led / defensive, > 65% = flight to safety."
summary={domSay}
activeIndex={domActive}
thresholds={[
{ label: '< 45% alt rotation', tone: 'up' },
{ label: '4555% balanced', tone: 'neutral' },
{ label: '5565% BTC-led', tone: 'neutral' },
{ label: '> 65% safety trade', tone: 'down' },
]}
chartHref="https://www.coinglass.com/pro/i/bitcoin-dominance"
chartLabel="CoinGlass"
/>
<MetricCard
rank={4}
label="ETH / BTC"
value={ind.eth_btc_ratio == null ? '—' : ind.eth_btc_ratio.toFixed(5)}
tone={toneEthBtc(ind.eth_btc_ratio)}
hint="Relative strength of ETH vs BTC. < 0.025 = defensive / BTC-led, 0.0250.04 = range, > 0.04 = ETH and alts gaining leadership."
summary={ebrSay}
activeIndex={ebrActive}
thresholds={[
{ label: '< 0.025 defensive', tone: 'down' },
{ label: '0.0250.04 range', tone: 'neutral' },
{ label: '> 0.04 ETH leadership', tone: 'up' },
]}
chartHref="https://www.coinglass.com/currencies/ETHBTC"
chartLabel="CoinGlass"
/>
</div>
</Section>
<Section title="Sentiment & flows">
<div className="macro-grid two">
<MetricCard
rank={5}
label="Fear & Greed"
value={ind.fear_greed == null ? '—' : String(ind.fear_greed)}
tone={toneFearGreed(ind.fear_greed)}
hint="Sentiment composite from alternative.me. CONTRARIAN read — extreme fear is bullish, extreme greed is bearish."
summary={fgSay}
activeIndex={fgActive}
thresholds={[
{ label: '< 25 → BUY (extreme fear)', tone: 'up' },
{ label: '2545 fear', tone: 'neutral' },
{ label: '4560 neutral', tone: 'neutral' },
{ label: '6075 greed', tone: 'neutral' },
{ label: '> 75 → SELL (extreme greed)', tone: 'down' },
]}
chartHref="https://www.coinglass.com/pro/i/FearGreedIndex?s=09"
chartLabel="CoinGlass"
/>
<MetricCard
rank={6}
label="Stablecoin Supply"
value={ind.stablecoin_supply_usd == null ? '—' : '$' + fmtUsd(ind.stablecoin_supply_usd, { compact: true })}
summary={stabSay}
hint="Sum of all USDT + USDC + DAI + others in circulation. Rising = dry powder accumulating; falling = capital exiting crypto."
activeIndex={stabActive}
thresholds={[
{ label: 'Rising = liquidity building', tone: 'up' },
{ label: 'Flat = neutral', tone: 'neutral' },
{ label: 'Falling = capital leaving', tone: 'down' },
]}
// CoinGlass doesn't expose an all-issuer stablecoin supply view
// cleanly; DeFiLlama is the canonical aggregator we pull from.
chartHref="https://defillama.com/stablecoins"
chartLabel="DeFiLlama"
/>
<MetricCard
rank={7}
label="ETF Net Flow (1d)"
value={fmtSignedUsd(ind.etf_flow_net_usd_1d)}
tone={toneEtfFlow(ind.etf_flow_net_usd_1d)}
hint="Yesterday's total net flow into/out of US spot BTC ETFs. > +$200M = strong bid, $50M to +$50M = noise, < $200M = heavy outflow."
summary={etfSay}
activeIndex={etfActive}
thresholds={[
{ label: '> +$200M strong bid', tone: 'up' },
{ label: '±$50M noise band', tone: 'neutral' },
{ label: '< $200M heavy outflow', tone: 'down' },
]}
chartHref="https://www.coinglass.com/etf"
/>
<MetricCard
rank={8}
label="BTC Open Interest"
value={ind.btc_open_interest_usd == null ? '—' : '$' + fmtUsd(ind.btc_open_interest_usd, { compact: true })}
summary={oiSay}
hint="Total notional in BTC futures. No universal absolute threshold: use OI with price. Rising OI + rising price = trend confirmation; rising OI + falling price = trapped longs."
thresholds={[
{ label: 'OI up + price up = confirmation', tone: 'up' },
{ label: 'OI up + price down = stress', tone: 'down' },
{ label: 'OI down after flush = reset', tone: 'neutral' },
]}
chartHref="https://www.tradingview.com/symbols/BTC_OI/"
chartLabel="TradingView"
/>
</div>
</Section>
<div className="macro-composite">
<div className="macro-composite-head">
<div>
<div className="macro-composite-label">Composite read</div>
<div className="macro-panel-subtitle">
Advisory blend of the metrics above. Useful for context, not for blind execution.
</div>
</div>
<div className="macro-composite-right">
<span className="macro-composite-value" style={{ color: compositeColor }}>
{snap.composite_score == null
? '—'
: (snap.composite_score > 0 ? '+' : '') + snap.composite_score.toFixed(1)}
</span>
{snap.regime_label ? (
<span
className="macro-regime-pill"
style={{
color: REGIME_COLOR[snap.regime_label] || 'var(--ink-3)',
borderColor: `color-mix(in oklab, ${REGIME_COLOR[snap.regime_label] || 'var(--ink-3)'} 32%, transparent)`,
background: `color-mix(in oklab, ${REGIME_COLOR[snap.regime_label] || 'var(--ink-3)'} 12%, transparent)`,
}}
>
{snap.regime_label}
</span>
) : null}
</div>
</div>
<div className="macro-composite-track" style={{ background: trackGradient }}>
{snap.composite_score != null ? (
<div
// Keying by score forces a remount → the @keyframes pulse on
// .macro-composite-needle re-fires every time the score moves.
key={snap.composite_score}
className="macro-composite-needle"
style={{
left: `calc(${compositePct}% - 10px)`,
borderColor: compositeColor,
color: compositeColor,
}}
/>
) : null}
</div>
<div className="macro-composite-scale">
{/* .word spans are hidden on screens ≤420px so the scale becomes
just "-100", "0", "+100" — fits comfortably on any phone. */}
<span>100 <span className="word">bear</span></span>
<span>0 <span className="word">neutral</span></span>
<span>+100 <span className="word">bull</span></span>
</div>
</div>
</div>
)
}