ui: tighten dashboard copy and fix layout issues
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
@@ -9,13 +9,14 @@ 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, type MacroSnapshot } from '@/lib/api'
|
||||
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.
|
||||
@@ -159,17 +160,42 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
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.
|
||||
@@ -199,7 +225,7 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness])
|
||||
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness, setPaperMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
@@ -255,9 +281,63 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
.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 }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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() {
|
||||
@@ -275,6 +355,50 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
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
|
||||
@@ -290,12 +414,21 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
|
||||
// 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.
|
||||
const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, 4)
|
||||
const recentOthers = posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4)
|
||||
// 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]
|
||||
@@ -322,12 +455,14 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
? ((displayPrice - baseline24h.open) / baseline24h.open) * 100
|
||||
: 0
|
||||
|
||||
const totalPosts = posts.length
|
||||
// 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 = posts.filter(p => p.signal === 'buy' || p.signal === 'short').length
|
||||
const trumpActionable = posts.filter(p => (p.source || '') === 'truth' && (p.signal === 'buy' || p.signal === 'short')).length
|
||||
const macroActionable = posts.filter(p => ((p.source || '') === 'btc_bottom_reversal' || (p.source || '') === 'funding_reversal') && (p.signal === 'buy' || p.signal === 'short')).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
|
||||
@@ -343,10 +478,9 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
// 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 == null ? (macroScore == null ? 'neutral' : 'neutral')
|
||||
: macroRegime === 'BULL' || macroRegime === 'BULLISH' ? 'bull'
|
||||
macroRegime === 'BULL' || macroRegime === 'BULLISH' ? 'bull'
|
||||
: macroRegime === 'BEAR' || macroRegime === 'BEARISH' ? 'bear'
|
||||
: 'neutral'
|
||||
: '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.'
|
||||
@@ -362,7 +496,14 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
Trump · Macro · KOL divergence — live.
|
||||
</PageHint>
|
||||
</div>
|
||||
<span className="chip"><span className="live-dot" />Live</span>
|
||||
{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
|
||||
@@ -387,21 +528,6 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
</div>
|
||||
<div className="overview-market-subtitle">{asset} · live signal context</div>
|
||||
</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>
|
||||
|
||||
<div className={`overview-macro-card ${macroTone}`}>
|
||||
@@ -488,17 +614,17 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
<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' },
|
||||
{ icon: '🔔', head: 'Telegram alerts', sub: 'Signal fires → your phone in seconds' },
|
||||
{ icon: '⚡', head: 'Auto-trade', sub: 'Trade-only key · no withdrawal access' },
|
||||
] as const).map(({ icon, head, sub }) => (
|
||||
<div key={head} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
{ 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}</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>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
@@ -532,7 +658,10 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
{' '}on <strong>{d.ticker}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
|
||||
Then {d.onchain_action.replace('_', ' ')}{d.usd_after != null ? ` → $${(d.usd_after / 1000).toFixed(0)}k on-chain` : ''}
|
||||
{/* /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>
|
||||
))}
|
||||
@@ -565,20 +694,27 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
{/* Left: Chart + signal stream */}
|
||||
<div className="stack gap-l">
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
<div className="row between" style={{ marginBottom: 18, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div className="tiny">Price · {asset}</div>
|
||||
<div className="row gap-m" style={{ marginTop: 6 }}>
|
||||
<AnimatedNumber
|
||||
className="hero-value mono"
|
||||
style={{ fontSize: 32 }}
|
||||
value={displayPrice}
|
||||
display={displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
|
||||
/>
|
||||
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
|
||||
{/* 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 className="tiny" style={{ color: 'var(--ink-3)' }}>Live chart with signal markers</div>
|
||||
</div>
|
||||
|
||||
{chartErr && (
|
||||
@@ -605,7 +741,7 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
</div>
|
||||
)}
|
||||
<ChartPanel
|
||||
posts={posts}
|
||||
posts={chartPosts}
|
||||
candles={candles}
|
||||
externalSelectedId={selectedPostId}
|
||||
onSelectPost={(id) => {
|
||||
@@ -620,10 +756,16 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="chart-footnote">
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#26a69a' }} /> Buy signal</div>
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#ef5350' }} /> Short signal</div>
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#aaaaaa' }} /> Hold / filtered</div>
|
||||
{asset === 'BTC' && <div className="item"><span className="legend-dot macro" /> Macro reversal highlight</div>}
|
||||
<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>
|
||||
@@ -632,10 +774,22 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
<div>
|
||||
<div className="section-title">
|
||||
<h2>Recent signals</h2>
|
||||
<span className="hint">
|
||||
{actionable.length > 0 ? `${actionable.length} actionable · ` : ''}
|
||||
{`${totalPosts} tracked`}
|
||||
</span>
|
||||
<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 => (
|
||||
@@ -655,7 +809,7 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Right rail */}
|
||||
<div className="rail">
|
||||
<div className="rail" ref={railRef}>
|
||||
{/* Day-view: all posts from clicked day */}
|
||||
{selectedDayPosts ? (
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
|
||||
@@ -238,43 +238,59 @@ export default function AnalyticsPageClient() {
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>{accuracy.total_directional_signals} directional signals measured</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 600, marginLeft: 'auto' }}>Public · no login needed</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8 }}>
|
||||
{/* Overall */}
|
||||
<div style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '12px 14px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>Overall</div>
|
||||
{(['m5','m15','m1h'] as const).map(w => {
|
||||
const d = accuracy.overall[w]
|
||||
const pct = d.accuracy_pct
|
||||
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
|
||||
return (
|
||||
<div key={w} className="row between" style={{ marginBottom: 3 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>{pct != null ? `${pct.toFixed(0)}%` : '—'}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{Object.entries(accuracy.by_signal).map(([sig, data]) => {
|
||||
const label = sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell'
|
||||
return (
|
||||
<div key={sig} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '12px 14px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{label} <span style={{ fontWeight: 400 }}>({data.count})</span></div>
|
||||
{(['m5','m15','m1h'] as const).map(w => {
|
||||
const d = data[w]
|
||||
if (d.checked < 2) return <div key={w} className="row between" style={{ marginBottom: 3 }}><span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span><span style={{ fontSize: 12, color: 'var(--ink-3)' }}>—</span></div>
|
||||
const pct = d.accuracy_pct
|
||||
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
|
||||
return (
|
||||
<div key={w} className="row between" style={{ marginBottom: 3 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>{pct.toFixed(0)}%</span>
|
||||
{(() => {
|
||||
// One full-width block per measurement window. Big overall number
|
||||
// + a hit-rate bar (50% tick = coin-flip baseline) + per-direction
|
||||
// breakdown. `repeat(3, 1fr)` stretches across the card so no
|
||||
// whitespace is stranded on wide screens.
|
||||
const WINDOWS = ['m5', 'm15', 'm1h'] as const
|
||||
const WINDOW_LABEL = { m5: 'After 5 min', m15: 'After 15 min', m1h: 'After 1 hour' } as const
|
||||
const pctColor = (pct: number) => pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
|
||||
const fmtBreakdown = (pct: number | null, sparse: boolean) =>
|
||||
sparse || pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%`
|
||||
// auto-fit, not repeat(3,1fr) — a hard 3-col grid overflowed the
|
||||
// viewport on phones and forced the whole page to scroll sideways.
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 12 }}>
|
||||
{WINDOWS.map(w => {
|
||||
const pct = accuracy.overall[w].accuracy_pct
|
||||
const has = pct != null && !Number.isNaN(pct)
|
||||
return (
|
||||
<div key={w} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '14px 16px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: '0.06em', display: 'flex', alignItems: 'center' }}>
|
||||
{WINDOW_LABEL[w]}
|
||||
<InfoTip text={`Share of signals where price had moved in the signalled direction ${w === 'm5' ? '5 minutes' : w === 'm15' ? '15 minutes' : '1 hour'} after the post. 50% = coin flip.`} placement="top" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 30, fontWeight: 600, letterSpacing: '-0.01em', marginTop: 8, color: has ? pctColor(pct) : 'var(--ink-4)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{has ? `${pct.toFixed(0)}%` : '—'}
|
||||
</div>
|
||||
{/* hit-rate bar with a 50% coin-flip tick */}
|
||||
<div style={{ position: 'relative', height: 6, background: 'var(--surface-3)', borderRadius: 999, marginTop: 10, overflow: 'hidden' }}>
|
||||
{has && (
|
||||
<div style={{ position: 'absolute', inset: 0, width: `${Math.min(100, Math.max(0, pct))}%`, background: pctColor(pct), borderRadius: 999, opacity: 0.75 }} />
|
||||
)}
|
||||
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 1.5, background: 'var(--ink-4)', opacity: 0.55 }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 12, fontSize: 12, color: 'var(--ink-3)', flexWrap: 'wrap' }}>
|
||||
{Object.entries(accuracy.by_signal).map(([sig, d]) => {
|
||||
const sparse = d[w].checked < 2
|
||||
const v = fmtBreakdown(d[w].accuracy_pct, sparse)
|
||||
const label = sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell'
|
||||
return (
|
||||
<span key={sig} style={{ whiteSpace: 'nowrap' }}>
|
||||
{label}{' '}
|
||||
<strong style={{ color: sparse ? 'var(--ink-4)' : 'var(--ink-2)', fontVariantNumeric: 'tabular-nums' }}>{v}</strong>
|
||||
<span style={{ color: 'var(--ink-4)' }}> ({d.count})</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -315,10 +331,21 @@ export default function AnalyticsPageClient() {
|
||||
<div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}>
|
||||
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
|
||||
</div>
|
||||
<div className="row gap-s" style={{ marginTop: 8 }}>
|
||||
<span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}</span>
|
||||
<span className="chip">{isZh ? `${filteredTrades.length} 笔交易` : `${filteredTrades.length} trades`}</span>
|
||||
</div>
|
||||
{/* Win-rate chip only when there are trades to rate — a red
|
||||
"0.0% win rate" next to an em-dash P&L misreads as a losing
|
||||
record when the wallet simply has no data. */}
|
||||
{pricedTrades.length > 0 && (
|
||||
<div className="row gap-s" style={{ marginTop: 8 }}>
|
||||
<span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}</span>
|
||||
</div>
|
||||
)}
|
||||
{filteredTrades.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 12, lineHeight: 1.6, maxWidth: 520 }}>
|
||||
No closed bot trades in this window. P&L, win rate and the
|
||||
per-trade metrics below fill in once auto-trade executions close
|
||||
— or pick a longer window on the top right.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -339,15 +366,14 @@ export default function AnalyticsPageClient() {
|
||||
{/* Signal accuracy is now shown at the top of the page (public, no login needed).
|
||||
Removed duplicate block here to avoid showing the same data twice. */}
|
||||
|
||||
{!filteredTrades.length && (
|
||||
{/* Empty-state card renders only for states no other element explains:
|
||||
- "window empty but trades exist" → Performance hero's inline note
|
||||
- privateLocked → the unlock card at the top of the page */}
|
||||
{!filteredTrades.length && !privateLocked && (!isConnected || !address || !trades.length) && (
|
||||
<div className="card" style={{ padding: 60, textAlign: 'center', color: 'var(--ink-3)' }}>
|
||||
<p style={{ fontSize: 14 }}>
|
||||
{!isConnected || !address
|
||||
? (isZh ? '连接钱包以加载你的分析数据。' : 'Connect your wallet to load your analytics.')
|
||||
: privateLocked
|
||||
? (isZh ? '签名解锁后可查看你的真实业绩和交易历史。' : 'Sign once above to unlock your personal P&L and trade history.')
|
||||
: trades.length
|
||||
? (isZh ? `${period} 时间窗内还没有已平仓交易。` : `No trades closed in the ${period} window yet.`)
|
||||
: (isZh ? '还没有交易数据。机器人开始执行后,这里会自动出现统计。' : 'No trade data yet. The bot will populate analytics once it starts executing.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -79,9 +79,6 @@ export default function ArchivePageClient({ initialData = null }: ArchivePageCli
|
||||
const archiveTotalPages = Math.max(1, Math.ceil(totalPosts / ARCHIVE_PAGE_SIZE))
|
||||
const archiveSafePage = Math.min(archivePage, archiveTotalPages)
|
||||
const allSourcesCount = sourceCounts.reduce((sum, item) => sum + item.count, 0)
|
||||
const selectedCount = src === 'all'
|
||||
? totalPosts
|
||||
: sourceCounts.find(s => s.source === src)?.count ?? totalPosts
|
||||
const sourceTabs: [string, number][] = [
|
||||
['all', allSourcesCount],
|
||||
...sourceCounts.map(item => [item.source, item.count] as [string, number]),
|
||||
@@ -92,7 +89,9 @@ export default function ArchivePageClient({ initialData = null }: ArchivePageCli
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Archive</h1>
|
||||
<PageHint count={`${selectedCount} legacy posts`}>
|
||||
{/* No count slot — the source tabs and pagination already show
|
||||
per-source and total counts. */}
|
||||
<PageHint>
|
||||
Signals from retired scanner experiments
|
||||
(rsi_reversal, sma_reclaim, breakout, test/phase1). Read-only —
|
||||
the bot no longer acts on any of these.
|
||||
|
||||
@@ -16,8 +16,19 @@ export default async function ArchivePage() {
|
||||
const initialData: PostListResponse | null = await getInitialPostPage(30, 1, {
|
||||
filters: { archiveOnly: true },
|
||||
legacyFallback: async () => {
|
||||
const legacyItems = await getPosts(500, 1).catch(() => null)
|
||||
return legacyItems ? buildArchiveFallbackResponse(legacyItems, 1, 30) : null
|
||||
// Old backend (no /posts-paged archive_only): /posts caps at 500 per
|
||||
// page, so a single page only sees the latest 500 posts globally — older
|
||||
// archive rows get buried under fresh truth posts and silently dropped.
|
||||
// Page through a few windows so archive coverage isn't truncated.
|
||||
const MAX_PAGES = 4 // up to 2000 most-recent posts scanned for archive rows
|
||||
const collected: Awaited<ReturnType<typeof getPosts>> = []
|
||||
for (let p = 1; p <= MAX_PAGES; p++) {
|
||||
const batch = await getPosts(500, p).catch(() => null)
|
||||
if (!batch || batch.length === 0) break
|
||||
collected.push(...batch)
|
||||
if (batch.length < 500) break // last page reached
|
||||
}
|
||||
return collected.length ? buildArchiveFallbackResponse(collected, 1, 30) : null
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -723,6 +723,34 @@ html[data-theme="dark"] .infotip-bubble {
|
||||
min-height: 66px;
|
||||
}
|
||||
|
||||
/* Hero variant — for a section with a SINGLE headline card (Valuation/AHR999).
|
||||
The stacked card layout stretched full-width left a huge dead middle; the
|
||||
hero lays out horizontally: value block left, summary + chips filling the
|
||||
middle, chart button right. Wraps back to stacked on narrow screens. */
|
||||
.macro-metric-card.hero {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: clamp(16px, 3vw, 40px);
|
||||
min-height: 0;
|
||||
}
|
||||
.macro-metric-card.hero .macro-hero-body {
|
||||
flex: 1 1 300px;
|
||||
min-width: 0;
|
||||
}
|
||||
.macro-metric-card.hero .macro-summary {
|
||||
margin-top: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.macro-metric-card.hero .macro-thresholds {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.macro-metric-card.hero .macro-actions {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.macro-thresholds {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1066,22 +1094,24 @@ html[data-theme="dark"] .macro-threshold-chip.neutral.active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
/* Placement variants (default = top) */
|
||||
/* Placement variants (default = top).
|
||||
--tip-shift is set by InfoTip.tsx on reveal: it nudges a bubble that would
|
||||
hang past the viewport edge back on-screen (icons near the screen margin). */
|
||||
.infotip-top .infotip-bubble {
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%; transform: translate(-50%, 2px);
|
||||
left: 50%; transform: translate(calc(-50% + var(--tip-shift, 0px)), 2px);
|
||||
}
|
||||
.infotip-top:hover .infotip-bubble,
|
||||
.infotip-top:focus-visible .infotip-bubble {
|
||||
transform: translate(-50%, 0);
|
||||
transform: translate(calc(-50% + var(--tip-shift, 0px)), 0);
|
||||
}
|
||||
.infotip-bottom .infotip-bubble {
|
||||
top: calc(100% + 8px);
|
||||
left: 50%; transform: translate(-50%, -2px);
|
||||
left: 50%; transform: translate(calc(-50% + var(--tip-shift, 0px)), -2px);
|
||||
}
|
||||
.infotip-bottom:hover .infotip-bubble,
|
||||
.infotip-bottom:focus-visible .infotip-bubble {
|
||||
transform: translate(-50%, 0);
|
||||
transform: translate(calc(-50% + var(--tip-shift, 0px)), 0);
|
||||
}
|
||||
.infotip-left .infotip-bubble {
|
||||
right: calc(100% + 8px);
|
||||
@@ -1562,6 +1592,10 @@ html[data-theme="dark"] .chip.down { color: oklch(80% 0.16 27); }
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
/* Grid items default to min-width:auto, so an intrinsically-wide child (the
|
||||
lightweight-charts canvas, a long URL) blows the column past the viewport
|
||||
on phones — the whole page then scrolls sideways. Let columns shrink. */
|
||||
.dash-grid > * { min-width: 0; }
|
||||
|
||||
.hero-value {
|
||||
font-size: 44px;
|
||||
@@ -2308,9 +2342,13 @@ html[data-theme="dark"] .post-row.signal-short {
|
||||
.src-ico.whale { background: oklch(94% 0.08 150); color: oklch(40% 0.15 150); font-size: 13px; }
|
||||
.src-ico.manual { background: oklch(94% 0.05 60); color: oklch(45% 0.15 60); font-size: 13px; }
|
||||
.src-ico.external { background: var(--bg-sunk); color: var(--ink-2); }
|
||||
/* min-width:0 — without it a long unbreakable URL in the text widens the 1fr
|
||||
grid column past the card and the right edge (meta tag included) is clipped. */
|
||||
.post-body { min-width: 0; }
|
||||
.post-body .meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
@@ -2321,6 +2359,7 @@ html[data-theme="dark"] .post-row.signal-short {
|
||||
line-height: 1.5;
|
||||
color: var(--ink);
|
||||
margin: 0 0 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.post-aside {
|
||||
display: flex;
|
||||
@@ -2812,8 +2851,15 @@ html[data-theme="dark"] .post-row.signal-short {
|
||||
CSS-hover-only tooltips require). */
|
||||
.infotip:active .infotip-bubble {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
/* Keep the viewport-clamp shift on tap — `transform: none` here used to
|
||||
discard it and re-centre the bubble off-screen. */
|
||||
.infotip-top:active .infotip-bubble,
|
||||
.infotip-bottom:active .infotip-bubble {
|
||||
transform: translate(calc(-50% + var(--tip-shift, 0px)), 0);
|
||||
}
|
||||
.infotip-left:active .infotip-bubble { transform: translate(0, -50%); }
|
||||
.infotip-right:active .infotip-bubble { transform: translate(0, -50%); }
|
||||
|
||||
.macro-action-label {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -230,7 +230,8 @@ function DigestTickerChip({
|
||||
border: active ? `2px solid ${sideColor}` : `1px solid ${sideColor}55`,
|
||||
boxShadow: active ? `0 10px 24px ${sideColor}22` : 'none',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
minHeight: 108,
|
||||
// No fixed minHeight — content is 3 short lines; 108px left the bottom
|
||||
// ~40% of every chip empty (worst on phones, 2 chips per row).
|
||||
width: '100%',
|
||||
transform: active ? 'translateY(-2px)' : 'none',
|
||||
opacity: hasActive && !active ? 0.6 : 1,
|
||||
@@ -479,15 +480,12 @@ function WalletCheckWidget({
|
||||
{row.verdict.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600, marginBottom: 4 }}>
|
||||
{/* Speakers + conviction dropped — the digest chip for the same
|
||||
ticker directly above already shows the handles and max
|
||||
conviction %. This column keeps only the one-line talk summary. */}
|
||||
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600 }}>
|
||||
{row.talkLine}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{row.speakers}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>
|
||||
{row.conviction}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
@@ -540,9 +538,9 @@ function WalletCheckWidget({
|
||||
}
|
||||
|
||||
function TickerChips({ tickers, isZh }: { tickers: KolTicker[]; isZh: boolean }) {
|
||||
if (!tickers.length) {
|
||||
return <span style={{ color: 'var(--ink-3)', fontSize: 12 }}>—</span>
|
||||
}
|
||||
// No tickers → render nothing. A lone "—" dangled under every card whose
|
||||
// post had no extracted assets (most of the feed).
|
||||
if (!tickers.length) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{tickers.map((t, i) => (
|
||||
@@ -811,7 +809,9 @@ export default function KolPage({
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">{'KOL Signals'}</h1>
|
||||
<PageHint count={`${serverTotal} posts`}>
|
||||
{/* No count slot — the digest meta line ("X posts · Y assets") and
|
||||
the feed pagination already show the totals. */}
|
||||
<PageHint>
|
||||
Arthur Hayes, Delphi, Bankless, and 22 more — their public calls vs what their wallets actually do.
|
||||
</PageHint>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { swrFetch } from '@/lib/cache'
|
||||
import PostRow from '@/components/dashboard/PostCards'
|
||||
import SystemControl from '@/components/signals/SystemControl'
|
||||
import InfoTip from '@/components/ui/InfoTip'
|
||||
import SignalMonitor from '@/components/dashboard/SignalMonitor'
|
||||
|
||||
// MacroPanel is 631 lines with heavy indicator math — split it out.
|
||||
const MacroPanel = dynamic(() => import('@/components/btc/MacroPanel'), {
|
||||
@@ -84,18 +83,9 @@ export default function MacroVibesPage({
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<h1 className="page-title" style={{ margin: 0 }}>{isZh ? '宏观氛围' : 'Macro Vibes'}</h1>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 700,
|
||||
background: 'var(--bg-sunk)',
|
||||
color: 'var(--ink-3)', border: '1px solid var(--line)',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
{isZh ? '手动开仓 · Bot 托管' : 'You open · bot manages'}
|
||||
</span>
|
||||
</div>
|
||||
{/* No "You open · bot manages" badge — the SystemControl strip right
|
||||
below says the same thing verbatim ("You open · bot manages exit"). */}
|
||||
<h1 className="page-title" style={{ margin: 0 }}>{isZh ? '宏观氛围' : 'Macro Vibes'}</h1>
|
||||
</div>
|
||||
<span className="chip"><span className="live-dot" />Live</span>
|
||||
</div>
|
||||
@@ -133,14 +123,17 @@ export default function MacroVibesPage({
|
||||
users are reasoning about the broader risk regime. The Funding tab
|
||||
has its own live funding panel below. */}
|
||||
{tab === 'bottom' && <MacroPanel />}
|
||||
{/* SignalMonitor (ETH/LINK Breakout Monitor) unmounted 2026-06-12: the
|
||||
backend scanner is disabled (services/funding_signal.py _enabled=False,
|
||||
operator-only toggle), so the panel only ever showed "Paused / No
|
||||
signals yet" — and breakout scanning is unrelated to funding reversal
|
||||
anyway. Component kept at components/dashboard/SignalMonitor.tsx;
|
||||
remount here if the scanner is ever re-enabled. */}
|
||||
{tab === 'funding' && (
|
||||
<>
|
||||
<FundingPanel
|
||||
isZh={isZh}
|
||||
initialSnapshot={initialFundingSnapshot}
|
||||
/>
|
||||
<SignalMonitor />
|
||||
</>
|
||||
<FundingPanel
|
||||
isZh={isZh}
|
||||
initialSnapshot={initialFundingSnapshot}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '16px 0 12px' }}>
|
||||
@@ -317,14 +310,10 @@ function FundingPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snap.history && snap.history.length > 1 && (
|
||||
<FundingSparkline
|
||||
history={snap.history}
|
||||
cadenceHours={snap.cadence_hours ?? 8}
|
||||
extremeCumPct={thr}
|
||||
isZh={isZh}
|
||||
/>
|
||||
)}
|
||||
{/* 7-day sparkline removed: the y-range was forced to include the ±threshold
|
||||
lines, so in normal regimes the curve rendered as a flat line on the zero
|
||||
axis with ~80% of the plot being empty danger-zone shading. The stat boxes
|
||||
above (latest / 24h avg / 30d cumulative / signal state) carry the signal. */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -352,73 +341,6 @@ function StatBox({ label, value, color, sub, hint }: {
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }}>
|
||||
|
||||
@@ -151,12 +151,26 @@ export default function TradesPageClient() {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">{isZh ? '交易执行' : 'Trades'}</h1>
|
||||
{/* Says what the page is, not where things sit — the section cards
|
||||
below are self-labelled ("Open positions", KPI row, table). */}
|
||||
<PageHint>
|
||||
Open positions above · closed trade history with realized P&L below.
|
||||
What the bot holds right now, and every trade it has closed.
|
||||
</PageHint>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest view: a zero-filled KPI row + empty table reads like a broken
|
||||
dashboard. Show one connect prompt instead and skip the data UI. */}
|
||||
{mounted && !isConnected ? (
|
||||
<div className="card" style={{ padding: '32px 28px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>Connect your wallet to see your trades</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.6 }}>
|
||||
Positions and trade history are wallet-bound and private.
|
||||
Use the Connect wallet button in the top-right corner.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{needsSetup && (
|
||||
<div className="card" style={{
|
||||
padding: '14px 18px', marginBottom: 16,
|
||||
@@ -210,6 +224,8 @@ export default function TradesPageClient() {
|
||||
)}
|
||||
|
||||
<TradeTable trades={trades} loading={loading} locked={needsUnlock} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -170,24 +170,12 @@ export default function TrumpSignalPage({ initialData = null }: TrumpSignalPageP
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<h1 className="page-title" style={{ margin: 0 }}>Trump Signal</h1>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 700,
|
||||
background: 'color-mix(in oklab, var(--up) 12%, transparent)',
|
||||
color: 'var(--up)', border: '1px solid color-mix(in oklab, var(--up) 25%, transparent)',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
⚡ Auto-trade
|
||||
</span>
|
||||
</div>
|
||||
<PageHint count={
|
||||
sigFilter === 'buy' ? `${counts.buy} buy signals`
|
||||
: sigFilter === 'short' ? `${counts.short} short signals`
|
||||
: sigFilter === 'actionable' ? `${counts.actionable} actionable signals`
|
||||
: `${counts.actionable} actionable / ${counts.all} posts`
|
||||
}>
|
||||
{/* No "⚡ Auto-trade" badge — the SystemControl strip right below
|
||||
always shows the Auto-Trade feature + its live state. */}
|
||||
<h1 className="page-title" style={{ margin: 0 }}>Trump Signal</h1>
|
||||
{/* No count slot — the filter tabs right below already show the
|
||||
per-filter numbers (All / Actionable / Buy / Short). */}
|
||||
<PageHint>
|
||||
Every Truth Social post scored in <3s. Trades only when conviction clears the threshold.
|
||||
</PageHint>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user