'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 = { 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 ( Open chart {children} ) } function ThresholdChip({ tone = 'neutral', active = false, children, }: { tone?: Tone active?: boolean children: ReactNode }) { const cls = `macro-threshold-chip ${tone}${active ? ' active' : ''}` return {children} } function MetricCard({ rank, label, value, tone = 'neutral', hint, summary, thresholds, activeIndex, chartHref, chartLabel = 'CoinGlass', hero = false, }: { rank: number label: string value: string tone?: Tone /** What the indicator IS, one sentence. Do NOT enumerate threshold bands * here — the chips below each card already list them. */ 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 /** Horizontal headline layout for a single full-width card (Valuation). * The stacked layout stretched across the panel left a dead middle. */ hero?: boolean }) { const head = (
{String(rank).padStart(2, '0')} {label}
{value}
) const chips = (
{thresholds.map((item, i) => ( {item.label} ))}
) const action = (
{chartLabel}
) if (hero) { return (
{head}
{summary}
{chips}
{action}
) } return (
{head}
{summary}
{chips} {action}
) } function GuideCard({ title, summary, thresholds, chartHref, chartLabel = 'CoinGlass', }: { title: string summary: string thresholds: Array<{ label: string; tone?: Tone }> chartHref: string chartLabel?: string }) { return (
{title}
{summary}
{thresholds.map((item) => ( {item.label} ))}
{chartLabel}
) } 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 (
{title}
{children}
) } export default function MacroPanel() { const [snap, setSnap] = useState(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 (
Couldn't load macro indicators — {err}
) } if (!snap) { return (
) } if (!snap.ok || !snap.indicators) { return (
Macro snapshot not available yet — {snap.error ?? 'cron has not run'}.
) } 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 (
Macro indicators
Daily snapshot for BTC macro context. Designed for scanning first, details second.
{snap.date}
free public sources
1.2 expensive', tone: 'down' }, ]} chartHref="https://www.coinglass.com/en/pro/i/ahr999" chartLabel="CoinGlass" />
65% safety trade', tone: 'down' }, ]} chartHref="https://www.coinglass.com/pro/i/bitcoin-dominance" chartLabel="CoinGlass" /> 0.04 ETH leadership', tone: 'up' }, ]} chartHref="https://www.coinglass.com/currencies/ETHBTC" chartLabel="CoinGlass" />
75 → SELL (extreme greed)', tone: 'down' }, ]} chartHref="https://www.coinglass.com/pro/i/FearGreedIndex?s=09" chartLabel="CoinGlass" /> +$200M strong bid', tone: 'up' }, { label: '±$50M noise band', tone: 'neutral' }, { label: '< −$200M heavy outflow', tone: 'down' }, ]} chartHref="https://www.coinglass.com/etf" />
Composite read
Advisory blend of the metrics above. Useful for context, not for blind execution.
{snap.composite_score == null ? '—' : (snap.composite_score > 0 ? '+' : '') + snap.composite_score.toFixed(1)} {snap.regime_label ? ( {snap.regime_label} ) : null}
{snap.composite_score != null ? (
) : null}
{/* .word spans are hidden on screens ≤420px so the scale becomes just "-100", "0", "+100" — fits comfortably on any phone. */} −100 bear 0 neutral +100 bull
) }