'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' /** * 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 BtcSignalPageProps { initialPosts?: TrumpPost[] | null initialFundingSnapshot?: FundingSnapshot | null } export default function BtcSignalPage({ initialPosts = null, initialFundingSnapshot = null, }: BtcSignalPageProps) { 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(initialPosts ?? []) const [loading, setLoading] = useState(initialPosts === null) const [loadErr, setLoadErr] = useState('') const [sentFilter, setSentFilter] = useState('all') const [tab, setTab] = useState('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 = { all: isZh ? '全部' : 'All', bullish: isZh ? '看多' : 'Bullish', bearish: isZh ? '看空' : 'Bearish', neutral: isZh ? '中性' : 'Neutral', } return (

{isZh ? '② BTC 信号' : '② BTC Signal'}

{tab === 'bottom' ? `Macro cycle bottom · ${btcPosts.length} signals` : `Funding-rate extreme reversal · ${btcPosts.length} signals`}

Live
{tab === 'bottom' && (
This module tracks Bitcoin macro-bottom conditions. It only fires a long-only cycle signal when at least two of three classic bottom markers agree: AHR999, the 200-week moving average, and Pi Cycle Bottom.
)}
{tab === 'bottom' && (
<> Fires when ≥2 of 3 classic bottom signals agree:{' '} AHR999 < 0.45 · price ≤ 200-week MA · Pi Cycle Bottom. Long only, low frequency (2–4 fires per cycle). Scans daily 00:45 UTC. Designed to ride the full cycle: holds up to 18 months, trailing-stop exit, no take-profit. Keep leverage ≤2× — amplification comes from pyramiding, not from cranking leverage.
)} {tab === 'funding' && ( )}
{SENTIMENTS.map(f => ( ))}
{loading && } {!loading && loadErr && (
⚠️ {`Couldn’t load signals — ${loadErr}`}
)} {!loading && !loadErr && filtered.length === 0 && (
{tab === 'bottom' ? 'No macro-bottom signals yet.' : 'No funding-reversal signals yet.'}
{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.'}
)} {!loading && filtered.length > 0 && (
{filtered.map(p => )}
)}
) } // ── Funding rate live panel ───────────────────────────────────────────────── function FundingPanel({ isZh, initialSnapshot = null, }: { isZh: boolean initialSnapshot?: FundingSnapshot | null }) { const [snap, setSnap] = useState(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 (
{`Funding snapshot failed — ${err}`}
) } if (!snap) { return (
) } if (!snap.ok) { return (
{`Funding data unavailable — ${snap.error || 'unknown error'}`}
) } // 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 (
BTC perp funding · live
{`${snap.cadence_hours}h cadence · ${snap.coverage_days}d coverage · Binance`}
= 0 ? '+' : ''}${cum.toFixed(2)}%`} color={cumColor} sub={`extreme @ ±${thr}%`} />
{directionHint} {snap.debug?.reason && ( ({String(snap.debug.reason).replaceAll('_', ' ')}) )}
{snap.history && snap.history.length > 1 && ( )}
) } function StatBox({ label, value, color, sub }: { label: string; value: string; color?: string; sub?: string }) { return (
{label}
{value}
{sub &&
{sub}
}
) } // 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 (
{isZh ? '7 日资金费率(单周期 %)' : '7-day funding (per-cycle %)'} {isZh ? `危险区间:每周期 ±${perCycleThr.toFixed(3)}%` : `danger band: ±${perCycleThr.toFixed(3)}% / cycle`}
{/* danger zones — shaded above +thr and below -thr */} {/* zero line */} {/* positive / negative threshold lines */} {/* funding curve */} {/* current value dot */}
) } function BtcSkeleton() { return (
{Array.from({ length: 3 }).map((_, i) => (
))}
) }