fix: Macro fetches by source, Analytics single-basis stats, funding venue label

- Macro page fetches getPosts(200, 1, source) per active tab instead of
  filtering the latest-500 global feed — rare bottom/funding signals no longer
  vanish behind Trump posts (backend now supports ?source=).
- Analytics: compute ALL time windows from the one closed_at-filtered trades
  list; dropped the 30d-only backend /performance special-case that mixed an
  opened_at basis into the same screen as the closed_at metric grid. Trade
  fetch raised 100→500 so the local computation covers ample history.
- Funding panel shows snap.venue (capitalized) instead of hardcoded "Binance".
- lib/api: getPosts gains optional source param; FundingSnapshot gains venue.

tsc + next build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-29 15:15:41 +08:00
parent 3ac1431336
commit a8522a2bf8
3 changed files with 39 additions and 27 deletions
+20 -17
View File
@@ -3,8 +3,8 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useLocale } from 'next-intl' import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi' import { useAccount, useSignMessage } from 'wagmi'
import { getPerformance, getTrades, getSignalAccuracy } from '@/lib/api' import { getTrades, getSignalAccuracy } from '@/lib/api'
import type { BotPerformance, BotTrade } from '@/types' import type { BotTrade } from '@/types'
import type { SignalAccuracy } from '@/lib/api' import type { SignalAccuracy } from '@/lib/api'
import { getCachedViewEnvelope, getOrCreateViewEnvelope, type SignedEnvelope } from '@/lib/signedRequest' import { getCachedViewEnvelope, getOrCreateViewEnvelope, type SignedEnvelope } from '@/lib/signedRequest'
import PageHint from '@/components/ui/PageHint' import PageHint from '@/components/ui/PageHint'
@@ -64,7 +64,6 @@ export default function AnalyticsPageClient() {
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { address, isConnected } = useAccount() const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage() const { signMessageAsync } = useSignMessage()
const [perf, setPerf] = useState<BotPerformance | null>(null)
const [trades, setTrades] = useState<BotTrade[]>([]) const [trades, setTrades] = useState<BotTrade[]>([])
const [accuracy, setAccuracy] = useState<SignalAccuracy | null>(null) const [accuracy, setAccuracy] = useState<SignalAccuracy | null>(null)
const [period, setPeriod] = useState<Period>('30d') const [period, setPeriod] = useState<Period>('30d')
@@ -81,24 +80,29 @@ export default function AnalyticsPageClient() {
// `forcedEnv` (a freshly-minted view_user) lets the in-page Unlock button // `forcedEnv` (a freshly-minted view_user) lets the in-page Unlock button
// load private data without a detour through the Settings page. Public // load private data without a detour through the Settings page. Public
// signal-accuracy always loads regardless. // signal-accuracy always loads regardless.
//
// All displayed stats are computed locally from this single trades list on
// ONE basis (closed_at, see inPeriod) for EVERY time window — we no longer
// mix in the backend /performance aggregate for 30d, which used a different
// basis (it filtered opened_at and capped at 30d) and produced numbers that
// disagreed with the closed_at-based metric grid on the same screen. Limit
// raised to 500 so the local computation covers ample history.
async function loadAll(forcedEnv?: SignedEnvelope) { async function loadAll(forcedEnv?: SignedEnvelope) {
if (!address || !isConnected) { if (!address || !isConnected) {
setPerf(null); setTrades([]); setAccuracy(null); setPrivateLocked(false) setTrades([]); setAccuracy(null); setPrivateLocked(false)
return return
} }
const sharedEnv = forcedEnv ?? getCachedViewEnvelope('view_user', address) const tradesEnv = getCachedViewEnvelope('view_trades', address)
const perfEnv = getCachedViewEnvelope('view_performance', address) ?? sharedEnv ?? (forcedEnv ?? getCachedViewEnvelope('view_user', address))
const tradesEnv = getCachedViewEnvelope('view_trades', address) ?? sharedEnv if (aliveRef.current) setPrivateLocked(!tradesEnv)
if (aliveRef.current) setPrivateLocked(!perfEnv && !tradesEnv)
try { try {
const [p, t, a] = await Promise.all([ const [t, a] = await Promise.all([
perfEnv ? getPerformance(address, perfEnv).catch(() => null) : Promise.resolve(null), tradesEnv ? getTrades(address, tradesEnv, 500, 1).catch(() => []) : Promise.resolve([]),
tradesEnv ? getTrades(address, tradesEnv, 100, 1).catch(() => []) : Promise.resolve([]),
getSignalAccuracy().catch(() => null), getSignalAccuracy().catch(() => null),
]) ])
if (aliveRef.current) { setPerf(p); setTrades(t); setAccuracy(a) } if (aliveRef.current) { setTrades(t); setAccuracy(a) }
} catch { } catch {
if (aliveRef.current) { setPerf(null); setTrades([]) } if (aliveRef.current) setTrades([])
} }
} }
@@ -139,10 +143,9 @@ export default function AnalyticsPageClient() {
const worstTrade = pnls.length ? Math.min(...pnls) : 0 const worstTrade = pnls.length ? Math.min(...pnls) : 0
const avgTrade = pnls.length ? totalPnl / pnls.length : 0 const avgTrade = pnls.length ? totalPnl / pnls.length : 0
const avgHold = filteredTrades.length ? filteredTrades.reduce((sum, trade) => sum + trade.hold_seconds, 0) / filteredTrades.length : 0 const avgHold = filteredTrades.length ? filteredTrades.reduce((sum, trade) => sum + trade.hold_seconds, 0) / filteredTrades.length : 0
const maxDrawdown = period === '30d' && perf ? perf.max_drawdown_pct : calcDrawdownPct(filteredTrades) // One basis for every window: derive from the closed_at-filtered trades.
const summaryPnl = period === '30d' && perf && trades.length >= filteredTrades.length const maxDrawdown = calcDrawdownPct(filteredTrades)
? perf.net_pnl_usd const summaryPnl = totalPnl
: totalPnl
const metrics = [ const metrics = [
{ k: isZh ? '最大回撤' : 'Max drawdown', v: filteredTrades.length ? maxDrawdown.toFixed(1) + '%' : '—', sub: isZh ? '从高点到低点的最大跌幅' : 'Worst peak-to-trough', down: true, { k: isZh ? '最大回撤' : 'Max drawdown', v: filteredTrades.length ? maxDrawdown.toFixed(1) + '%' : '—', sub: isZh ? '从高点到低点的最大跌幅' : 'Worst peak-to-trough', down: true,
+15 -8
View File
@@ -43,22 +43,27 @@ export default function MacroVibesPage({
const [sentFilter, setSentFilter] = useState<SentimentFilter>('all') const [sentFilter, setSentFilter] = useState<SentimentFilter>('all')
const [tab, setTab] = useState<BtcTab>('bottom') const [tab, setTab] = useState<BtcTab>('bottom')
// Tab-scoped post source. 'bottom' = MVRV/200WMA confluence,
// 'funding' = funding-rate extreme reversal.
const tabSource = tab === 'bottom' ? 'btc_bottom_reversal' : 'funding_reversal'
useEffect(() => { useEffect(() => {
// BTC on-chain signals update daily — 30 min TTL is safe // Fetch BY SOURCE, not the latest-500 global feed. These scanner signals
// are rare (24/cycle, hourly) and were being pushed off the latest-500
// page by frequent Trump posts — making the Macro page look empty even
// when signals existed. Server-side source filter guarantees we get them.
setLoading(true)
swrFetch( swrFetch(
'posts-500', `posts-src-${tabSource}`,
3 * 60_000, 3 * 60_000,
() => getPosts(500, 1), () => getPosts(200, 1, tabSource),
fresh => setPosts(fresh), fresh => setPosts(fresh),
) )
.then(p => { setPosts(p); setLoadErr('') }) .then(p => { setPosts(p); setLoadErr('') })
.catch(e => setLoadErr(e instanceof Error ? e.message : (isZh ? '信号加载失败' : 'Failed to load signals'))) .catch(e => setLoadErr(e instanceof Error ? e.message : (isZh ? '信号加载失败' : 'Failed to load signals')))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [isZh]) }, [tabSource, isZh])
// Tab-scoped post source. 'bottom' = MVRV/200WMA confluence,
// 'funding' = funding-rate extreme reversal.
const tabSource = tab === 'bottom' ? 'btc_bottom_reversal' : 'funding_reversal'
const btcPosts = useMemo( const btcPosts = useMemo(
() => posts.filter(p => (p.source || '') === tabSource), () => posts.filter(p => (p.source || '') === tabSource),
[posts, tabSource], [posts, tabSource],
@@ -270,7 +275,9 @@ function FundingPanel({
BTC perp funding · live BTC perp funding · live
</div> </div>
<div style={{ fontSize: 11, color: 'var(--ink-4)' }}> <div style={{ fontSize: 11, color: 'var(--ink-4)' }}>
{`${snap.cadence_hours}h cadence · ${snap.coverage_days}d coverage · Binance`} {`${snap.cadence_hours}h cadence · ${snap.coverage_days}d coverage · ${
snap.venue ? snap.venue.charAt(0).toUpperCase() + snap.venue.slice(1) : 'Binance'
}`}
</div> </div>
</div> </div>
+4 -2
View File
@@ -29,8 +29,9 @@ async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
return res.json() as Promise<T> return res.json() as Promise<T>
} }
export async function getPosts(limit = 20, page = 1): Promise<TrumpPost[]> { export async function getPosts(limit = 20, page = 1, source?: string): Promise<TrumpPost[]> {
return fetchJson<TrumpPost[]>(`/posts?limit=${limit}&page=${page}`) const q = `/posts?limit=${limit}&page=${page}` + (source ? `&source=${encodeURIComponent(source)}` : '')
return fetchJson<TrumpPost[]>(q)
} }
export async function getPost(id: number): Promise<TrumpPost> { export async function getPost(id: number): Promise<TrumpPost> {
@@ -459,6 +460,7 @@ export interface FundingSnapshot {
ok: boolean ok: boolean
error?: string error?: string
asset?: string asset?: string
venue?: string
cadence_hours?: number cadence_hours?: number
coverage_days?: number coverage_days?: number
latest_rate_pct?: number latest_rate_pct?: number