d72323b1c6
Big-picture changes since 01be8e7:
New routes — /trump /btc /kol /trades /analytics /archive plus four
SEO landing pages (/methodology /glossary /case-studies /contact),
all served under /[locale]. Each has dedicated metadata + JSON-LD.
KOL page (new) — DigestWidget + OnchainWidget + TalkVsTradesWidget,
filter by handle/ticker, click-through to per-post detail with the
original AI-extracted ticker/conviction/quote.
BTC page (new) — tabbed: macro-bottom (AHR999 + 200WMA + Pi Cycle)
and funding-rate reversal, with live sparkline + threshold bands.
Telegram card on Settings — wallet-link code generation, status,
disconnect. Preferences moved into the bot itself (/trump /btc etc.)
so the card stays minimal.
SignalMonitor (new) — ETH/LINK Bollinger breakout monitor in its own
component, shares the singleton WsProvider so no second WS opens.
WS singleton refactor (lib/wsContext) — shared WsProvider + useWsSubscribe
hook. Cleanup now actively closes the socket on unmount; previously the
local `ws` couldn't be reached from cleanup and leaked one connection
per StrictMode remount.
OpenPositions polling no longer pops MetaMask in the background —
splits into load('first') for user-initiated and load('poll') that
uses getCachedViewEnvelope without signing.
i18n shelved — proxy.ts (Next 16 middleware rename) wires next-intl
but only Navbar + layout footer have translations. Rest of UI used
isZh ternary scattered across 28 files. All `const isZh = locale === 'zh'`
flipped to `const isZh = false` so every site renders English; Chinese
branches kept as dead code so revival is one regex away.
LanguageSwitch hidden but file kept. zh-CN hreflang removed from
metadata + sitemap to avoid duplicate-content penalties.
Wallet error handling — lib/walletError.ts: isUserRejection walks EIP-1193
code 4001 + .cause chain; previous string-match for "reject"/"denied"
broke for users running MetaMask in non-English UIs. 12 call sites
migrated across 5 components.
PWA icons — app/icon.tsx + app/apple-icon.tsx render the brand "α" via
Next's ImageResponse so no static PNG asset is required. manifest.ts
references /icon and /apple-icon dynamic routes.
OG image + sitemap + robots — dynamic 1200×630 OG card; robots blocks
both /en/settings and /zh/settings; sitemap only emits /en routes;
JSON-LD covers SoftwareApplication + Organization + 10-Q FAQPage.
Landing page polish — Launch Dashboard button stripped of magnetic
hover + shimmer + lift + glow expansion (multiple users found it busy);
hero scramble alphabet swapped to alphanumeric (was block characters);
"15 KOL feeds" copy updated to 19 in 5 places.
PostCards source icons — new entries for btc_bottom_reversal,
funding_reversal, kol_divergence so they no longer fall through to
the generic "first letter" fallback.
Archive page filter — excludes funding_reversal + kol_divergence
(previously only excluded truth + btc_bottom_reversal so new live
signals leaked into the legacy archive).
Cache + skeleton loading — lib/cache.ts SWR module with per-key TTL,
applied across the major pages so navigation feels instant.
SignConfirmSheet — 4 hardcoded Chinese strings translated to English
(English users were seeing zh-only "需要钱包签名" etc).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
418 lines
16 KiB
TypeScript
418 lines
16 KiB
TypeScript
'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<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 ? '② BTC 信号' : '② BTC Signal'}</h1>
|
||
<p className="page-sub">
|
||
{tab === 'bottom'
|
||
? `Macro cycle bottom · ${btcPosts.length} signals`
|
||
: `Funding-rate extreme reversal · ${btcPosts.length} signals`}
|
||
</p>
|
||
</div>
|
||
<span className="chip"><span className="live-dot" />Live</span>
|
||
</div>
|
||
|
||
<SystemControl system="btc" />
|
||
|
||
{tab === 'bottom' && (
|
||
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6, marginBottom: 12 }}>
|
||
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.
|
||
</div>
|
||
)}
|
||
|
||
<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>
|
||
|
||
{tab === 'bottom' && (
|
||
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5, marginBottom: 12 }}>
|
||
<>
|
||
Fires when <strong>≥2 of 3</strong> classic bottom signals agree:{' '}
|
||
<strong>AHR999 < 0.45</strong> · <strong>price ≤ 200-week
|
||
MA</strong> · <strong>Pi Cycle Bottom</strong>. Long only, low frequency
|
||
(2–4 fires per cycle). Scans daily 00:45 UTC. Designed to ride the
|
||
full cycle: holds up to <strong>18 months</strong>, trailing-stop
|
||
exit, no take-profit. Keep leverage ≤2× — amplification comes from
|
||
pyramiding, not from cranking leverage.
|
||
</>
|
||
</div>
|
||
)}
|
||
|
||
{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)' }}>
|
||
⚠️ {`Couldn’t 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 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 (
|
||
<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?.toFixed(4)}%`}
|
||
/>
|
||
<StatBox
|
||
label="Last 24h avg"
|
||
value={`${snap.last_24h_avg_pct?.toFixed(4)}%`}
|
||
/>
|
||
<StatBox
|
||
label="30d cumulative"
|
||
value={`${cum >= 0 ? '+' : ''}${cum.toFixed(2)}%`}
|
||
color={cumColor}
|
||
sub={`extreme @ ±${thr}%`}
|
||
/>
|
||
<StatBox
|
||
label="Signal"
|
||
value={snap.signal_fired
|
||
? '🚨 FIRED'
|
||
: extreme
|
||
? '⏳ Watching'
|
||
: '✓ Quiet'}
|
||
color={snap.signal_fired ? 'var(--down)' : extreme ? 'var(--amber, #f59e0b)' : 'var(--up)'}
|
||
/>
|
||
</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 }: {
|
||
label: string; value: string; color?: string; sub?: string
|
||
}) {
|
||
return (
|
||
<div style={{
|
||
padding: 10, background: 'var(--bg-sunk)', borderRadius: 6,
|
||
border: '1px solid var(--line)',
|
||
}}>
|
||
<div style={{ fontSize: 10, color: 'var(--ink-4)', textTransform: 'uppercase',
|
||
letterSpacing: '.06em', marginBottom: 4 }}>{label}</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>
|
||
)
|
||
}
|