925 lines
45 KiB
TypeScript
925 lines
45 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useRef } from 'react'
|
||
import dynamic from 'next/dynamic'
|
||
import Link from 'next/link'
|
||
import { useParams } from 'next/navigation'
|
||
import { useLocale } from 'next-intl'
|
||
import { useAccount } from 'wagmi'
|
||
import type { TrumpPost, BotPerformance, Candle } from '@/types'
|
||
import { useDashboardStore } from '@/store/dashboard'
|
||
import { usePriceSocket } from '@/lib/useRealtimeData'
|
||
import { getMacroSnapshot, getPerformance, getPrices, getUserPublic, getKolDivergence, getKolDigest, getPostsPage, type MacroSnapshot } from '@/lib/api'
|
||
import type { KolDivergence, KolDigest } from '@/types'
|
||
import { getCachedViewEnvelope } from '@/lib/signedRequest'
|
||
import { swrFetch, invalidate as invalidateCache } from '@/lib/cache'
|
||
import PostRow, { SignalPill, SourceIcon, SOURCE_DISPLAY, fmtPct, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
|
||
import OpenPositions from '@/components/positions/OpenPositions'
|
||
import PageHint from '@/components/ui/PageHint'
|
||
import InfoTip from '@/components/ui/InfoTip'
|
||
import AnimatedNumber from '@/components/ui/AnimatedNumber'
|
||
|
||
// Heavy components — lazy-loaded so they don't bloat the initial JS bundle.
|
||
// ChartPanel pulls in lightweight-charts (~200KB gz); split it out.
|
||
const ChartPanel = dynamic(() => import('@/components/dashboard/ChartPanel'), {
|
||
ssr: false,
|
||
loading: () => <div style={{ height: 320, background: 'var(--bg-sunk)', borderRadius: 12, marginBottom: 16 }} />,
|
||
})
|
||
|
||
interface Props {
|
||
initialPosts: TrumpPost[]
|
||
}
|
||
|
||
// ── Inline post detail panel shown in the right rail ──────────────────────────
|
||
function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void }) {
|
||
const impact = post.price_impact
|
||
|
||
function fmtImpactPct(v: number | null | undefined) {
|
||
if (v == null || isNaN(v)) return '—'
|
||
const s = Math.abs(v).toFixed(2) + '%'
|
||
return v >= 0 ? '+' + s : '-' + s
|
||
}
|
||
|
||
return (
|
||
<div className="card" style={{ padding: 20 }}>
|
||
{/* header */}
|
||
<div className="row between" style={{ marginBottom: 14 }}>
|
||
<span className="tiny">Signal detail</span>
|
||
<button
|
||
className="icon-btn"
|
||
onClick={onClose}
|
||
style={{ width: 26, height: 26, borderRadius: '50%' }}
|
||
title="Close"
|
||
>
|
||
<svg width="10" height="10" viewBox="0 0 24 24">
|
||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* source + time */}
|
||
<div className="row gap-s" style={{ marginBottom: 12 }}>
|
||
<SourceIcon source={post.source} />
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 500, fontFamily: 'var(--mono)' }}>
|
||
{SOURCE_DISPLAY[post.source?.toLowerCase?.()]?.label ?? post.source ?? 'Signal'}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}><TimeAgo iso={post.published_at} suffix=" ago" /> · <LocalDateTime iso={post.published_at} /></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* post text */}
|
||
<p style={{ fontSize: 14, lineHeight: 1.55, margin: '0 0 16px', color: 'var(--ink)' }}>{post.text}</p>
|
||
|
||
{/* signal + sentiment */}
|
||
<div className="row gap-s" style={{ marginBottom: 16 }}>
|
||
<SignalPill signal={post.signal} />
|
||
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`}>
|
||
{post.sentiment}
|
||
</span>
|
||
</div>
|
||
|
||
{/* AI confidence */}
|
||
<div className="ai-metric-head">
|
||
<span>AI confidence</span>
|
||
<strong>{post.ai_confidence}%</strong>
|
||
</div>
|
||
<div className="confidence-bar" style={{ marginBottom: 16 }}>
|
||
<div style={{ width: post.ai_confidence + '%' }} />
|
||
</div>
|
||
|
||
{/* AI reasoning */}
|
||
{post.ai_reasoning && (
|
||
<>
|
||
<div className="ai-reasoning-label">
|
||
{post.signal === 'buy' || post.signal === 'short' ? 'Why this signal fired' : 'Why this was filtered out'}
|
||
</div>
|
||
<div className="ai-reasoning-card scroll" style={{ marginBottom: 16 }}>
|
||
{post.ai_reasoning}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Price impact grid */}
|
||
{impact && (
|
||
<>
|
||
<div className="tiny" style={{ marginBottom: 10 }}>Price impact · {impact.asset}</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||
{([['m5', '5m'], ['m15', '15m'], ['m1h', '1h']] as const).map(([key, label]) => {
|
||
const v = impact[key]
|
||
const correct = impact[`correct_${key}` as 'correct_m5' | 'correct_m15' | 'correct_m1h']
|
||
return (
|
||
<div key={key} style={{
|
||
padding: 10,
|
||
background: 'var(--bg-sunk)',
|
||
borderRadius: 'var(--r-sm)',
|
||
border: '1px solid var(--line)',
|
||
textAlign: 'center',
|
||
}}>
|
||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4 }}>{label}</div>
|
||
<div className={`delta ${(v ?? 0) >= 0 ? 'up' : 'down'}`} style={{ fontSize: 14, fontWeight: 600 }}>
|
||
{fmtImpactPct(v)}
|
||
</div>
|
||
{correct != null && (
|
||
<div style={{ fontSize: 10, marginTop: 4, color: correct ? 'var(--up)' : 'var(--down)' }}>
|
||
{correct ? '✓' : '✗'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Empty state for when nothing is selected ──────────────────────────────────
|
||
function SelectHint() {
|
||
return (
|
||
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--ink-3)' }}>
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" style={{ margin: '0 auto 12px', display: 'block', opacity: 0.4 }}>
|
||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2"/>
|
||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||
</svg>
|
||
<p style={{ fontSize: 13, margin: 0, lineHeight: 1.5 }}>
|
||
Click any marker on the chart or a post below to see details
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main dashboard ─────────────────────────────────────────────────────────────
|
||
export default function DashboardClient({ initialPosts }: Props) {
|
||
const intlLocale = useLocale()
|
||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, setPaperMode, isSubscribed, hlApiKeySet, botReadiness, livePrices } = useDashboardStore()
|
||
function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') }
|
||
const { address, isConnected } = useAccount()
|
||
const params = useParams()
|
||
const locale = (typeof params?.locale === 'string' ? params.locale : 'en')
|
||
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
|
||
// Chart markers need actionable (buy/short) posts, but the 80-post
|
||
// first-paint slice is usually ALL hold/filtered — Trump posts are mostly
|
||
// off-topic, so the chart would render zero markers. Fetch signal posts
|
||
// separately and merge them in for the chart.
|
||
const [signalPosts, setSignalPosts] = useState<TrumpPost[]>([])
|
||
// Distinguishes "actionable fetch not done yet" from "loaded, zero
|
||
// actionable" — the auto-select effect waits on it before falling back.
|
||
const [signalPostsLoaded, setSignalPostsLoaded] = useState(false)
|
||
const [performance, setPerformance] = useState<BotPerformance | undefined>(undefined)
|
||
const [candles, setCandles] = useState<Candle[]>([])
|
||
const [hideFiltered, setHideFiltered] = useState(false)
|
||
const [chartErr, setChartErr] = useState('')
|
||
const [chartReload, setChartReload] = useState(0)
|
||
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
|
||
const railRef = useRef<HTMLDivElement>(null)
|
||
const [macro, setMacro] = useState<MacroSnapshot | null>(null)
|
||
const [kolDivergences, setKolDivergences] = useState<KolDivergence[]>([])
|
||
const [kolDigest, setKolDigest] = useState<KolDigest | null>(null)
|
||
// For 1D: show all posts from selected day
|
||
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
|
||
|
||
// Server-side aggregate counts. The Overview headline numbers (total tracked,
|
||
// actionable, Trump/Macro signals) must reflect the WHOLE feed, not the
|
||
// 80-post first-paint slice — otherwise a site with 1108 posts / 25 actionable
|
||
// Trump signals showed "80 tracked" and "—". Loaded once from /posts-paged
|
||
// which returns server-computed totals + counts without shipping the rows.
|
||
const [serverCounts, setServerCounts] = useState<{
|
||
total: number; actionable: number; trump: number; macro: number
|
||
} | null>(null)
|
||
|
||
// Backend degraded state. When the process isn't the singleton leader it
|
||
// runs NO background tasks (no scrapers, price feeds, scheduler) — the data
|
||
// is frozen and "Live" would be a lie. /api/health/deep returns 503 + a body
|
||
// with { status, is_leader } in that case.
|
||
const [degraded, setDegraded] = useState(false)
|
||
|
||
useEffect(() => {
|
||
// B42: cancel the in-flight getUserPublic if the wallet switches again
|
||
// before it resolves — prevents old wallet's data polluting new wallet's state.
|
||
let cancelled = false
|
||
if (!isConnected || !address) {
|
||
setSubscribed(false)
|
||
setHlApiKeySet(false)
|
||
setBotReadiness('unknown')
|
||
setPerformance(undefined)
|
||
return () => { cancelled = true }
|
||
}
|
||
// Clear account-scoped bot state immediately (setWallet already resets these
|
||
// via store, but DashboardClient may be rendered without a wallet switch —
|
||
// keep explicit resets here for clarity and safety).
|
||
setSubscribed(false)
|
||
setHlApiKeySet(false)
|
||
setBotReadiness('unknown')
|
||
// `snapAddr !== address` would be a stale-closure trap: both are the same
|
||
// closed-over value. Use `cancelled` (set by effect cleanup) as the sole guard.
|
||
getUserPublic(address.toLowerCase())
|
||
.then((user) => {
|
||
if (cancelled) return // effect cleaned up = wallet changed or unmounted
|
||
setSubscribed(user.active)
|
||
setHlApiKeySet(user.hl_api_key_set)
|
||
setBotReadiness(user.hl_api_key_set ? 'saved' : 'unknown')
|
||
setPaperMode(!!user.paper_mode)
|
||
})
|
||
.catch(() => {})
|
||
return () => { cancelled = true }
|
||
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness, setPaperMode])
|
||
|
||
useEffect(() => {
|
||
if (!isConnected || !address) {
|
||
setPerformance(undefined)
|
||
return
|
||
}
|
||
let cancelled = false
|
||
;(async () => {
|
||
try {
|
||
const env = getCachedViewEnvelope('view_performance', address)
|
||
?? getCachedViewEnvelope('view_user', address)
|
||
if (!env) {
|
||
if (!cancelled) setPerformance(undefined)
|
||
return
|
||
}
|
||
const data = await getPerformance(address, env)
|
||
if (!cancelled) setPerformance(data)
|
||
} catch {
|
||
if (!cancelled) setPerformance(undefined)
|
||
}
|
||
})()
|
||
return () => { cancelled = true }
|
||
}, [address, isConnected])
|
||
|
||
const [freshPostId, setFreshPostId] = useState<number | null>(null)
|
||
|
||
usePriceSocket({
|
||
onPrice: (a, price) => setLivePrice(a, price),
|
||
onNewPost: (post) => {
|
||
const p = post as TrumpPost
|
||
// Dedup: WS may resend a post already in initialPosts or a prior push.
|
||
setPosts((prev) => prev.some(x => x.id === p.id) ? prev : [p, ...prev].slice(0, 500))
|
||
setFreshPostId(p.id)
|
||
// Keep a ref to the timer so we can cancel it if the component unmounts
|
||
// before it fires (avoids setState-after-unmount warning).
|
||
const timer = setTimeout(() => setFreshPostId((id) => (id === p.id ? null : id)), 1400)
|
||
return () => clearTimeout(timer) // returned but not used by usePriceSocket — see L1 note
|
||
},
|
||
})
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
setCandles([])
|
||
setChartErr('')
|
||
const priceKey = `prices-${asset}-${timeframe}`
|
||
if (chartReload > 0) invalidateCache(priceKey)
|
||
swrFetch(
|
||
priceKey,
|
||
90_000,
|
||
() => getPrices(asset, timeframe),
|
||
fresh => { if (!cancelled) { setCandles(fresh); setChartErr('') } },
|
||
)
|
||
.then(c => { if (!cancelled) { setCandles(c); setChartErr('') } })
|
||
.catch(e => { if (!cancelled) setChartErr(e instanceof Error ? e.message : 'Failed to load price data') })
|
||
return () => { cancelled = true }
|
||
}, [asset, timeframe, chartReload])
|
||
|
||
// Auto-select the latest actionable signal once on load so the detail rail
|
||
// isn't an empty column until the user clicks something. Runs once; never
|
||
// re-selects after the user picks or closes a post.
|
||
const autoSelectedRef = useRef(false)
|
||
const skipRailScrollRef = useRef(false)
|
||
useEffect(() => {
|
||
if (autoSelectedRef.current) return
|
||
if (selectedPostId != null || selectedDayPosts) { autoSelectedRef.current = true; return }
|
||
const pool = [...posts, ...signalPosts]
|
||
const actionable = pool.filter(p => p.signal === 'buy' || p.signal === 'short')
|
||
// Prefer the latest actionable signal — but the actionable fetch is async,
|
||
// so wait for it before falling back to a (likely off-topic) recent post.
|
||
if (!actionable.length && !signalPostsLoaded) return
|
||
const pick = (actionable.length ? actionable : pool)
|
||
.slice()
|
||
.sort((a, b) => +new Date(b.published_at) - +new Date(a.published_at))[0]
|
||
if (!pick) return
|
||
autoSelectedRef.current = true
|
||
skipRailScrollRef.current = true // a load-time selection must not scroll the page
|
||
setSelectedPostId(pick.id)
|
||
}, [posts, signalPosts, signalPostsLoaded, selectedPostId, selectedDayPosts])
|
||
|
||
// On narrow viewports the layout stacks and the detail rail sits ABOVE the
|
||
// chart — a click on a post down in the list renders the detail card out of
|
||
// view and the selection looks like a no-op. Scroll it into view, but only
|
||
// when it's actually outside the viewport (no jump on desktop, where the
|
||
// rail is a sticky side column).
|
||
useEffect(() => {
|
||
if (selectedPostId == null && !selectedDayPosts) return
|
||
if (skipRailScrollRef.current) { skipRailScrollRef.current = false; return }
|
||
const el = railRef.current
|
||
if (!el) return
|
||
const r = el.getBoundingClientRect()
|
||
// The detail card renders at the TOP of the rail, so "visible" means the
|
||
// rail's top edge is on screen — a rail whose tail end pokes into the
|
||
// viewport still hides the card.
|
||
if (r.top < 0 || r.top > window.innerHeight - 120) {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
}, [selectedPostId, selectedDayPosts])
|
||
|
||
// Actionable posts for the chart markers (see signalPosts above).
|
||
useEffect(() => {
|
||
let alive = true
|
||
swrFetch(
|
||
'posts-actionable-chart',
|
||
90_000,
|
||
() => getPostsPage(100, 1, undefined, { signal: 'actionable' }),
|
||
fresh => { if (alive) setSignalPosts(fresh.items) },
|
||
)
|
||
.then(r => { if (alive) { setSignalPosts(r.items); setSignalPostsLoaded(true) } })
|
||
.catch(() => { if (alive) setSignalPostsLoaded(true) })
|
||
return () => { alive = false }
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
let alive = true
|
||
function load() {
|
||
swrFetch(
|
||
'macro-snapshot',
|
||
10 * 60_000,
|
||
() => getMacroSnapshot(),
|
||
fresh => { if (alive) setMacro(fresh) },
|
||
)
|
||
.then((snap) => { if (alive) setMacro(snap) })
|
||
.catch(() => {})
|
||
}
|
||
load()
|
||
const id = setInterval(load, 10 * 60_000)
|
||
return () => { alive = false; clearInterval(id) }
|
||
}, [])
|
||
|
||
// Poll backend health so the "Live" chip can downgrade to "Delayed" when the
|
||
// process is a non-leader follower (read-only, no background tasks → data is
|
||
// frozen). The body is present on both 200 and 503, so read it regardless of
|
||
// status. Key ONLY on is_leader === false: a `degraded` status can also mean
|
||
// a single price feed briefly lagged, which would flap the chip even though
|
||
// the feed self-heals in seconds — that's noisier than it is useful.
|
||
useEffect(() => {
|
||
let alive = true
|
||
async function check() {
|
||
try {
|
||
const res = await fetch('/api/proxy/api/health/deep')
|
||
const body = await res.json().catch(() => null)
|
||
if (!alive || !body) return
|
||
setDegraded(body.is_leader === false)
|
||
} catch { /* network error — leave chip as-is */ }
|
||
}
|
||
check()
|
||
const id = setInterval(check, 60_000)
|
||
return () => { alive = false; clearInterval(id) }
|
||
}, [])
|
||
|
||
// Server-side aggregate counts for the Overview headline numbers. limit=1
|
||
// keeps the payload tiny — we only consume `total` and `counts`.
|
||
useEffect(() => {
|
||
let alive = true
|
||
;(async () => {
|
||
try {
|
||
const [global, trump, macro] = await Promise.all([
|
||
getPostsPage(1, 1),
|
||
getPostsPage(1, 1, 'truth'),
|
||
getPostsPage(1, 1, undefined, { sourceIn: ['btc_bottom_reversal', 'funding_reversal'] }),
|
||
])
|
||
if (!alive) return
|
||
setServerCounts({
|
||
total: global.total,
|
||
actionable: global.counts.actionable,
|
||
trump: trump.counts.actionable,
|
||
macro: macro.counts.actionable,
|
||
})
|
||
} catch { /* fall back to local-slice counts */ }
|
||
})()
|
||
return () => { alive = false }
|
||
}, [])
|
||
|
||
// KOL divergence + digest — loaded once, 30 min cache (changes daily)
|
||
useEffect(() => {
|
||
let alive = true
|
||
// KOL ingestion is daily and sparse — a 7d window is frequently empty
|
||
// (e.g. 7d=0 while 30d=196), which hid the whole sidebar card. Use a 30d
|
||
// window so the Overview reflects the data that actually exists.
|
||
swrFetch('kol-divergence-30d', 30 * 60_000, () => getKolDivergence({ days: 30 }), f => { if (alive) setKolDivergences(f.items ?? []) })
|
||
.then(r => { if (alive) setKolDivergences(r.items ?? []) })
|
||
.catch(() => {})
|
||
swrFetch('kol-digest-30d', 30 * 60_000, () => getKolDigest(30), f => { if (alive) setKolDigest(f) })
|
||
.then(r => { if (alive) setKolDigest(r) })
|
||
.catch(() => {})
|
||
return () => { alive = false }
|
||
}, [])
|
||
|
||
// Union of the recent-posts slice and the separately-fetched actionable
|
||
// posts (deduped) — feeds the chart so signal markers always render, and
|
||
// the detail lookup so clicking one of those markers resolves.
|
||
const chartPosts = (() => {
|
||
const seen = new Set(posts.map(p => p.id))
|
||
return [...posts, ...signalPosts.filter(p => !seen.has(p.id))]
|
||
})()
|
||
|
||
const selectedPost = chartPosts.find(p => p.id === selectedPostId) ?? null
|
||
|
||
// Show actionable signals first (buy/short), then most recent hold/neutral.
|
||
// Cap at 8 total so the list doesn't get too long. "Signals only" hides the
|
||
// hold/filtered (gray) posts entirely — same toggle as the Trump page.
|
||
const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, hideFiltered ? 8 : 4)
|
||
const recentOthers = hideFiltered ? [] : posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4)
|
||
const recentPosts = [...actionable, ...recentOthers].slice(0, 8)
|
||
|
||
const lastCandle = candles[candles.length - 1]
|
||
// Live price beats the most recent candle close — the candle is only
|
||
// refreshed when the user changes asset/timeframe, so without WS the number
|
||
// is frozen at page load.
|
||
const livePrice = livePrices[asset]
|
||
const displayPrice = livePrice ?? lastCandle?.close ?? null
|
||
|
||
// 24-hour change. Picking by candle index is wrong (200×4H = 33 days).
|
||
// Find the candle whose `time` is closest to (now − 24h) and use its open.
|
||
const now = lastCandle ? lastCandle.time : Math.floor(Date.now() / 1000)
|
||
const target24h = now - 24 * 3600
|
||
let baseline24h: typeof lastCandle | undefined
|
||
if (candles.length) {
|
||
baseline24h = candles[0]
|
||
for (const c of candles) {
|
||
if (c.time <= target24h) baseline24h = c
|
||
else break
|
||
}
|
||
}
|
||
const priceChange =
|
||
displayPrice != null && baseline24h
|
||
? ((displayPrice - baseline24h.open) / baseline24h.open) * 100
|
||
: 0
|
||
|
||
// Prefer server-computed totals (whole feed); fall back to the local 80-post
|
||
// slice only until serverCounts loads (or if that fetch failed).
|
||
const totalPosts = serverCounts?.total ?? posts.length
|
||
const todayKey = new Date().toISOString().slice(0, 10)
|
||
const signalsToday = posts.filter(p => p.published_at?.slice(0, 10) === todayKey).length
|
||
const actionablePosts = serverCounts?.actionable ?? posts.filter(p => p.signal === 'buy' || p.signal === 'short').length
|
||
const trumpActionable = serverCounts?.trump ?? posts.filter(p => (p.source || '') === 'truth' && (p.signal === 'buy' || p.signal === 'short')).length
|
||
const macroActionable = serverCounts?.macro ?? posts.filter(p => ((p.source || '') === 'btc_bottom_reversal' || (p.source || '') === 'funding_reversal') && (p.signal === 'buy' || p.signal === 'short')).length
|
||
// Use actual KOL divergence count (divergence-flagged items from the API),
|
||
// not a source==='kol' post count (which never matches — source is 'substack'/'blog'/etc.).
|
||
const kolMentions = kolDivergences.filter(d => d.signal_type === 'divergence').length
|
||
const winRate = performance?.win_rate ?? 0
|
||
const netPnl = performance?.net_pnl_usd ?? 0
|
||
const hasPriceData = candles.length > 0
|
||
const hasPerformanceData = Boolean(performance)
|
||
const macroScore = macro?.composite_score ?? null
|
||
const macroRegime = macro?.regime_label ?? null
|
||
const macroPct = macroScore == null ? 50 : Math.max(0, Math.min(100, (macroScore + 100) / 2))
|
||
// Derive tone from the backend's regime_label, NOT a re-thresholded score.
|
||
// The backend uses ±20 for BULLISH/BEARISH; re-deriving with ±15 here made
|
||
// a score of e.g. 15.2 read "Supportive" in the card while the same card's
|
||
// regime label said NEUTRAL. Trusting regime_label keeps them consistent.
|
||
const macroTone =
|
||
macroRegime === 'BULL' || macroRegime === 'BULLISH' ? 'bull'
|
||
: macroRegime === 'BEAR' || macroRegime === 'BEARISH' ? 'bear'
|
||
: 'neutral' // covers NEUTRAL and the null/not-loaded case
|
||
const macroSummary =
|
||
macroScore == null ? 'Daily macro composite not loaded yet.'
|
||
: macroTone === 'bull' ? 'Supportive backdrop — trend setups have room.'
|
||
: macroTone === 'bear' ? 'Defensive backdrop — preserve size, downside moves are cleaner.'
|
||
: 'Mixed backdrop — context only, not a directional trigger.'
|
||
|
||
return (
|
||
<div className="page wide">
|
||
<div className="page-head">
|
||
<div>
|
||
<h1 className="page-title">{isZh ? '信号总览' : 'Signal monitor'}</h1>
|
||
<PageHint count={actionablePosts > 0 ? `${actionablePosts} actionable · ${totalPosts} tracked` : undefined}>
|
||
Trump · Macro · KOL divergence — live.
|
||
</PageHint>
|
||
</div>
|
||
{degraded ? (
|
||
<span className="chip" title="Backend is running read-only (not the singleton leader) — no background tasks, values may be delayed."
|
||
style={{ color: 'var(--amber-ink)', borderColor: 'color-mix(in oklab, var(--amber) 35%, var(--line))' }}>
|
||
<span className="live-dot" style={{ background: 'var(--amber)' }} />Delayed
|
||
</span>
|
||
) : (
|
||
<span className="chip"><span className="live-dot" />Live</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Open positions — what's on the book right now. Renders only when
|
||
a subscribed wallet is connected, so guests see the normal feed. */}
|
||
<OpenPositions />
|
||
|
||
|
||
<div className="overview-shell">
|
||
<div className="overview-main">
|
||
<section className="overview-market-card">
|
||
<div className="overview-card-head">
|
||
<div>
|
||
<div className="overview-kicker">Market and macro</div>
|
||
<div className="overview-headline-row">
|
||
<AnimatedNumber
|
||
className="hero-value mono"
|
||
style={{ fontSize: 40 }}
|
||
value={displayPrice}
|
||
display={displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
|
||
/>
|
||
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
|
||
</div>
|
||
<div className="overview-market-subtitle">{asset} · live signal context</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`overview-macro-card ${macroTone}`}>
|
||
<div className="overview-macro-band">
|
||
<div className="overview-macro-copy">
|
||
<div className="overview-macro-title-row">
|
||
<div className="overview-macro-title">Macro composite</div>
|
||
<div className="overview-macro-stamp">today · 8 indicators</div>
|
||
</div>
|
||
<div className="overview-macro-text">{macroSummary}</div>
|
||
</div>
|
||
<div className="overview-macro-score">
|
||
<div className={`overview-score-value ${macroTone}`}>
|
||
{macroScore == null ? '—' : `${macroScore > 0 ? '+' : ''}${macroScore.toFixed(1)}`}
|
||
</div>
|
||
<div className={`overview-score-pill ${macroTone}`}>{macroRegime ?? 'Waiting'}</div>
|
||
</div>
|
||
</div>
|
||
<div className="overview-score-track">
|
||
<div className={`overview-score-needle ${macroTone}`} style={{ left: `calc(${macroPct}% - 11px)` }} />
|
||
</div>
|
||
<div className="overview-score-scale">
|
||
<span>−100 bear</span>
|
||
<span>0 neutral</span>
|
||
<span>+100 bull</span>
|
||
</div>
|
||
</div>
|
||
|
||
</section>
|
||
|
||
{/* ── Unified stats row ─────────────────────────────────────────── */}
|
||
<div className="overview-stats-bar">
|
||
{([
|
||
{ label: 'Trump signals', value: trumpActionable, href: `/${locale}/trump` },
|
||
{ label: 'Macro signals', value: macroActionable, href: `/${locale}/macro` },
|
||
{ label: 'KOL divergence', value: kolMentions, href: `/${locale}/kol` },
|
||
] as const).map(({ label, value, href }) => {
|
||
const empty = value === 0
|
||
const inner = (
|
||
<>
|
||
<span className="stats-bar-label">{label}</span>
|
||
<span className="stats-bar-value" style={{ color: empty ? 'var(--ink-4)' : 'var(--ink)' }}>
|
||
{empty ? '—' : value}
|
||
</span>
|
||
</>
|
||
)
|
||
return <Link key={label} href={href} className="stats-bar-item">{inner}</Link>
|
||
})}
|
||
<div className="stats-bar-item passive" style={{ borderRight: 'none' }}>
|
||
<span className="stats-bar-label">My P&L · 30d</span>
|
||
<span className="stats-bar-value" style={{
|
||
color: hasPerformanceData ? (netPnl >= 0 ? 'var(--up)' : 'var(--down)') : 'var(--ink-4)',
|
||
}}>
|
||
{hasPerformanceData
|
||
? `${netPnl >= 0 ? '+' : '−'}$${Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}`
|
||
: '—'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<aside className="overview-side">
|
||
{isConnected ? (
|
||
<section className="overview-side-card">
|
||
<div className="overview-kicker">Account</div>
|
||
<div className="overview-account-list">
|
||
<div className="overview-account-item">
|
||
<span>Wallet</span>
|
||
<strong className="mono">{address ? `${address.slice(0, 6)}…${address.slice(-4)}` : '—'}</strong>
|
||
</div>
|
||
<div className="overview-account-item">
|
||
<span>Settings</span>
|
||
<strong>{hasPerformanceData ? 'Loaded' : 'Not loaded'}</strong>
|
||
</div>
|
||
{hasPerformanceData && (
|
||
<div className="overview-account-item">
|
||
<span>Win rate</span>
|
||
<strong>{`${(winRate * 100).toFixed(1)}%`}</strong>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
) : (
|
||
<section className="overview-side-card" style={{ padding: '14px 16px' }}>
|
||
<div className="overview-kicker" style={{ marginBottom: 12 }}>Get started</div>
|
||
{([
|
||
{ icon: '📡', head: 'Free signals', sub: 'Trump · Macro · KOL — no account needed', href: `/${locale}/trump` },
|
||
{ icon: '🔔', head: 'Telegram alerts', sub: 'Signal fires → your phone in seconds', href: `/${locale}/settings` },
|
||
{ icon: '⚡', head: 'Auto-trade', sub: 'Trade-only key · no withdrawal access', href: `/${locale}/trades` },
|
||
] as const).map(({ icon, head, sub, href }) => (
|
||
<Link key={head} href={href} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 12, textDecoration: 'none', color: 'inherit' }}>
|
||
<span style={{ fontSize: 16, flexShrink: 0, marginTop: 1 }}>{icon}</span>
|
||
<div>
|
||
<div style={{ fontSize: 12, fontWeight: 700, lineHeight: 1.3 }}>{head} <span style={{ color: 'var(--ink-4)', fontWeight: 400 }}>↗</span></div>
|
||
<div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4, marginTop: 1 }}>{sub}</div>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</section>
|
||
)}
|
||
{/* KOL divergence hook — highest-conviction signal type */}
|
||
{(kolDivergences.length > 0 || (kolDigest && kolDigest.tickers.length > 0)) && (
|
||
<section className="overview-side-card" style={{ padding: '14px 16px' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||
<div className="overview-kicker">KOL intel · 30d</div>
|
||
<a href={`/${locale}/kol`} style={{ fontSize: 10, color: 'var(--amber-ink)', textDecoration: 'none', fontWeight: 700 }}>
|
||
Full feed ↗
|
||
</a>
|
||
</div>
|
||
|
||
{/* Latest divergence — the money signal */}
|
||
{kolDivergences.filter(d => d.signal_type === 'divergence').slice(0, 1).map(d => (
|
||
<div key={d.id} style={{
|
||
padding: '9px 11px', borderRadius: 7, marginBottom: 8,
|
||
background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.25)',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||
<span style={{ fontSize: 10, fontWeight: 800, color: '#f59e0b', letterSpacing: '0.05em' }}>⚠️ DIVERGENCE</span>
|
||
<span style={{ fontSize: 10, color: 'var(--ink-4)' }}>
|
||
{/* post_at = when the KOL actually published. created_at
|
||
is the DB write time, which a backfill/late scan can
|
||
set to "now", making an old event look fresh. */}
|
||
{Math.round((Date.now() - new Date(d.post_at).getTime()) / 864e5)}d ago
|
||
</span>
|
||
</div>
|
||
<div style={{ fontSize: 12, fontWeight: 600 }}>
|
||
@{d.handle} said <span style={{ color: d.post_action === 'bullish' || d.post_action === 'buy' ? 'var(--up)' : 'var(--down)' }}>{d.post_action}</span>
|
||
{' '}on <strong>{d.ticker}</strong>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
|
||
{/* /1000+"k" alone printed "$2100k" for $2.1M positions */}
|
||
Then {d.onchain_action.replace('_', ' ')}{d.usd_after != null
|
||
? ` → ${d.usd_after >= 1e6 ? '$' + (d.usd_after / 1e6).toFixed(1) + 'M' : '$' + (d.usd_after / 1e3).toFixed(0) + 'K'} on-chain`
|
||
: ''}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Top digest tickers */}
|
||
{kolDigest && kolDigest.tickers.slice(0, 2).map(t => (
|
||
<div key={t.ticker} style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '6px 0', borderBottom: '1px solid var(--line)',
|
||
}}>
|
||
<div>
|
||
<span style={{ fontSize: 12, fontWeight: 700 }}>{t.ticker}</span>
|
||
<span style={{ fontSize: 10, color: 'var(--ink-4)', marginLeft: 6 }}>{t.kol_count} KOLs</span>
|
||
</div>
|
||
<span style={{
|
||
fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 4,
|
||
background: t.side === 'long' ? 'var(--up-soft)' : t.side === 'short' ? 'rgba(220,38,38,.12)' : 'var(--bg-sunk)',
|
||
color: t.side === 'long' ? 'var(--up)' : t.side === 'short' ? 'var(--down)' : 'var(--ink-3)',
|
||
}}>
|
||
{t.dominant_action.toUpperCase()}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</section>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
|
||
<div className="dash-grid">
|
||
{/* Left: Chart + signal stream */}
|
||
<div className="stack gap-l">
|
||
<div className="card" style={{ padding: 24 }}>
|
||
{/* The big price lives in the market hero above — repeating it here
|
||
just duplicated the same number 32px tall. The chart's own price
|
||
axis label shows the live price; this header instead hosts the
|
||
asset/timeframe controls right next to the chart they drive. */}
|
||
<div className="row between" style={{ marginBottom: 14, alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||
<div className="tiny">Live chart · {asset} · signal markers</div>
|
||
<div className="overview-controls">
|
||
<div className="asset-switch">
|
||
<button className={asset === 'BTC' ? 'on' : ''} onClick={() => setAsset('BTC')}>
|
||
<span className="asset-dot btc" /> BTC
|
||
</button>
|
||
<button className={asset === 'ETH' ? 'on' : ''} onClick={() => setAsset('ETH')}>
|
||
<span className="asset-dot eth" /> ETH
|
||
</button>
|
||
</div>
|
||
<div className="tf-bar">
|
||
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
|
||
<button key={t} className={timeframe === t ? 'on' : ''} onClick={() => setTimeframe(t)}>{t}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{chartErr && (
|
||
<div style={{ padding: '8px 12px', margin: '0 0 8px', fontSize: 12,
|
||
color: 'var(--down)', background: 'var(--down-soft, rgba(220,38,38,.08))',
|
||
borderRadius: 8 }}>
|
||
⚠️ {asset} {timeframe} chart failed to load — {chartErr}{' '}
|
||
<button className="btn ghost" style={{ fontSize: 11, padding: '3px 10px', marginLeft: 6 }}
|
||
onClick={() => setChartReload(n => n + 1)}>Retry</button>
|
||
</div>
|
||
)}
|
||
<div className="chart-wrap" style={{ position: 'relative' }}>
|
||
{/* Loading overlay — shown while candles are fetching (candles=[] and no error).
|
||
Prevents the user from seeing a blank lightweight-charts canvas. */}
|
||
{!hasPriceData && !chartErr && (
|
||
<div style={{
|
||
position: 'absolute', inset: 0, zIndex: 4,
|
||
background: 'var(--bg-sunk)', borderRadius: 'var(--r-sm)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
gap: 10, color: 'var(--ink-3)', fontSize: 13,
|
||
}}>
|
||
<span className="live-dot" style={{ background: 'var(--amber)' }} />
|
||
Loading chart…
|
||
</div>
|
||
)}
|
||
<ChartPanel
|
||
posts={chartPosts}
|
||
candles={candles}
|
||
externalSelectedId={selectedPostId}
|
||
onSelectPost={(id) => {
|
||
setSelectedDayPosts(null)
|
||
setSelectedPostId(id)
|
||
}}
|
||
onSelectDayPosts={(dayPosts) => {
|
||
setSelectedDayPosts(dayPosts)
|
||
setSelectedPostId(null)
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="chart-footnote">
|
||
<div className="item">
|
||
Markers
|
||
<InfoTip
|
||
placement="top"
|
||
width={280}
|
||
text={asset === 'BTC'
|
||
? 'Green dot = buy, red = short; a number is the signal count in that candle. Amber dashed line = macro reversal signal.'
|
||
: 'Green dot = buy, red = short; a number is the signal count in that candle.'}
|
||
/>
|
||
</div>
|
||
<div style={{ marginLeft: 'auto' }}>Binance candles via backend API · click marker to inspect</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Recent signals list */}
|
||
<div>
|
||
<div className="section-title">
|
||
<h2>Recent signals</h2>
|
||
<button
|
||
onClick={() => setHideFiltered(v => !v)}
|
||
title="Hide hold/filtered (gray) posts and show only buy/short signals"
|
||
style={{
|
||
padding: '4px 10px', borderRadius: 6,
|
||
border: '1px solid var(--line)',
|
||
background: hideFiltered ? 'var(--ink)' : 'transparent',
|
||
color: hideFiltered ? 'var(--bg)' : 'var(--ink-3)',
|
||
fontSize: 11, cursor: 'pointer', fontWeight: 600,
|
||
marginLeft: 'auto', marginRight: 8,
|
||
}}
|
||
>
|
||
{hideFiltered ? 'Show all' : 'Signals only'}
|
||
</button>
|
||
{/* No count hint here — the page-head PageHint already shows
|
||
"X actionable · Y tracked" for the whole feed. */}
|
||
</div>
|
||
<div className="post-stream">
|
||
{recentPosts.map(p => (
|
||
<div key={p.id} className={p.id === freshPostId ? 'signal-enter' : undefined}>
|
||
<PostRow post={p} selected={selectedPostId === p.id} onClick={() => {
|
||
// Clear the day-list view first: the right panel renders
|
||
// selectedDayPosts with higher priority than selectedPost,
|
||
// so without this the detail never appears while a candle's
|
||
// multi-post list is open.
|
||
setSelectedDayPosts(null)
|
||
setSelectedPostId(selectedPostId === p.id ? null : p.id)
|
||
}} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right rail */}
|
||
<div className="rail" ref={railRef}>
|
||
{/* Day-view: all posts from clicked day */}
|
||
{selectedDayPosts ? (
|
||
<div className="card" style={{ padding: 20 }}>
|
||
<div className="row between" style={{ marginBottom: 14 }}>
|
||
<div>
|
||
<div className="tiny">{selectedDayPosts.length} posts · this candle</div>
|
||
<div style={{ fontSize: 15, fontWeight: 600, marginTop: 2 }}>
|
||
<LocalDateTime iso={selectedDayPosts[0].published_at} opts={{ month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }} />
|
||
</div>
|
||
</div>
|
||
<button className="icon-btn" onClick={() => { setSelectedDayPosts(null); setSelectedPostId(null) }}
|
||
style={{ width:26, height:26, borderRadius:'50%' }}>
|
||
<svg width="10" height="10" viewBox="0 0 24 24">
|
||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div style={{ display:'flex', flexDirection:'column', gap:10 }}>
|
||
{selectedDayPosts.map(p => {
|
||
const expanded = selectedPostId === p.id
|
||
const impact = p.price_impact
|
||
function fmtImpactPct(v: number | null | undefined) {
|
||
if (v == null || isNaN(v)) return '—'
|
||
const s = Math.abs(v).toFixed(2) + '%'
|
||
return v >= 0 ? '+' + s : '-' + s
|
||
}
|
||
return (
|
||
<div key={p.id}
|
||
style={{ border:`1px solid ${expanded?'var(--amber)':'var(--line)'}`,
|
||
borderRadius:'var(--r-sm)', overflow:'hidden', transition:'border-color 0.15s' }}>
|
||
{/* collapsed row — always visible */}
|
||
<div onClick={() => setSelectedPostId(expanded ? null : p.id)}
|
||
style={{ padding:12, cursor:'pointer',
|
||
background: expanded ? 'var(--bg-sunk)' : 'transparent' }}>
|
||
<div className="row between" style={{ marginBottom:6 }}>
|
||
<div className="row gap-s">
|
||
<SignalPill signal={p.signal} />
|
||
<span className={`chip ${p.sentiment === 'bullish' ? 'up' : p.sentiment === 'bearish' ? 'down' : 'neutral'}`}
|
||
style={{ fontSize:10 }}>{p.sentiment}</span>
|
||
</div>
|
||
<span style={{ fontSize:11, color:'var(--ink-3)' }}><TimeAgo iso={p.published_at} /></span>
|
||
</div>
|
||
<p style={{ fontSize:12, lineHeight:1.5, margin:'0 0 8px', color:'var(--ink-2)',
|
||
...(expanded ? {} : {
|
||
display:'-webkit-box', WebkitLineClamp: 3,
|
||
WebkitBoxOrient:'vertical', overflow:'hidden'
|
||
}) }}>
|
||
{p.text}
|
||
</p>
|
||
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
|
||
<div style={{ flex:1, height:3, background:'var(--line)', borderRadius:2, overflow:'hidden' }}>
|
||
<div style={{ width:p.ai_confidence+'%', height:'100%', background:'var(--amber)', borderRadius:2 }} />
|
||
</div>
|
||
<span style={{ fontSize:10, color:'var(--ink-3)', whiteSpace:'nowrap' }}>{p.ai_confidence}% conf</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* expanded detail */}
|
||
{expanded && (
|
||
<div style={{ padding:'0 12px 12px', borderTop:'1px solid var(--line)' }}>
|
||
{p.ai_reasoning && (
|
||
<>
|
||
<div className="ai-reasoning-label" style={{ margin:'10px 0 8px' }}>AI reasoning</div>
|
||
<div className="ai-reasoning-card" style={{ padding: '14px 15px', fontSize: 14, lineHeight: 1.7 }}>
|
||
{p.ai_reasoning}
|
||
</div>
|
||
</>
|
||
)}
|
||
{impact && (
|
||
<>
|
||
<div className="tiny" style={{ margin:'10px 0 6px' }}>Price impact · {impact.asset}</div>
|
||
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:6 }}>
|
||
{(['m5','m15','m1h'] as const).map((key) => {
|
||
const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h'
|
||
const v = impact[key]
|
||
const correct = impact[`correct_${key}` as 'correct_m5'|'correct_m15'|'correct_m1h']
|
||
return (
|
||
<div key={key} style={{ padding:8, background:'var(--bg-sunk)',
|
||
borderRadius:'var(--r-sm)', border:'1px solid var(--line)', textAlign:'center' }}>
|
||
<div style={{ fontSize:10, color:'var(--ink-3)', marginBottom:3 }}>{label}</div>
|
||
<div className={`delta ${(v??0)>=0?'up':'down'}`} style={{ fontSize:13, fontWeight:600 }}>
|
||
{fmtImpactPct(v)}
|
||
</div>
|
||
{correct != null && (
|
||
<div style={{ fontSize:10, marginTop:3, color: correct ? 'var(--up)' : 'var(--down)' }}>
|
||
{correct ? '✓' : '✗'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : selectedPost ? (
|
||
<PostDetail post={selectedPost} onClose={() => setSelectedPostId(null)} />
|
||
) : (
|
||
<SelectHint />
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|