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>
632 lines
24 KiB
TypeScript
632 lines
24 KiB
TypeScript
'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'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.45–0.75 = fair value leaning cheap, 0.75–1.2 = neutral / no edge, > 1.2 = expensive."
|
||
summary={ahrSay}
|
||
activeIndex={ahrActive}
|
||
thresholds={[
|
||
{ label: '< 0.45 buy zone', tone: 'up' },
|
||
{ label: '0.45–0.75 fair value', tone: 'neutral' },
|
||
{ label: '0.75–1.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, 25–60 = mixed, 60–75 = alt strength building, ≥ 75 = altseason."
|
||
summary={altSay}
|
||
activeIndex={altActive}
|
||
thresholds={[
|
||
{ label: '< 25 Bitcoin season', tone: 'down' },
|
||
{ label: '25–60 mixed', tone: 'neutral' },
|
||
{ label: '60–75 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, 45–55% = balanced, 55–65% = BTC-led / defensive, > 65% = flight to safety."
|
||
summary={domSay}
|
||
activeIndex={domActive}
|
||
thresholds={[
|
||
{ label: '< 45% alt rotation', tone: 'up' },
|
||
{ label: '45–55% balanced', tone: 'neutral' },
|
||
{ label: '55–65% 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.025–0.04 = range, > 0.04 = ETH and alts gaining leadership."
|
||
summary={ebrSay}
|
||
activeIndex={ebrActive}
|
||
thresholds={[
|
||
{ label: '< 0.025 defensive', tone: 'down' },
|
||
{ label: '0.025–0.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: '25–45 fear', tone: 'neutral' },
|
||
{ label: '45–60 neutral', tone: 'neutral' },
|
||
{ label: '60–75 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>
|
||
)
|
||
}
|