Files
trumpsignal-frontend/app/[locale]/macro/MacroVibesPageClient.tsx
T
k a1e9ec49ef fix: 3 display bugs found in full-page audit
1. MacroVibesPageClient — funding stats rendered "undefined%" when
   latest_rate_pct / last_24h_avg_pct were null. `?.toFixed()` returns
   undefined inside a template literal → shows as the string "undefined%".
   Fixed: explicit null check → '—' fallback.

2. SettingsClient — broken internal anchor: href="#btc-settings-panel"
   but the section id is "btc-settings". "Open panel ↓" link did nothing.
   Fixed: corrected href to "#btc-settings".

3. KolPageClient — undefined CSS variables throughout the file:
   --bg-card (transparent) and --border (no border) were never defined
   in the design system (globals.css uses --surface and --line).
   KOL cards rendered with transparent bg and invisible borders.
   Fixed: --bg-card → --surface, --border → --line, --accent → --amber.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:28:05 +08:00

441 lines
17 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 { 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&apos;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&nbsp;&lt;&nbsp;0.45</strong> · <strong>price 200-week MA</strong>
{' '}· <strong>Pi Cycle Bottom</strong>. Long only, 24 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)' }}>
⚠️ {`Couldnt 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 18h, 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 == null ? '' : `${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 == null ? '' : `${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>
)
}