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 { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import { getPerformance, getTrades, getSignalAccuracy } from '@/lib/api'
import type { BotPerformance, BotTrade } from '@/types'
import { getTrades, getSignalAccuracy } from '@/lib/api'
import type { BotTrade } from '@/types'
import type { SignalAccuracy } from '@/lib/api'
import { getCachedViewEnvelope, getOrCreateViewEnvelope, type SignedEnvelope } from '@/lib/signedRequest'
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 { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const [perf, setPerf] = useState<BotPerformance | null>(null)
const [trades, setTrades] = useState<BotTrade[]>([])
const [accuracy, setAccuracy] = useState<SignalAccuracy | null>(null)
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
// load private data without a detour through the Settings page. Public
// 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) {
if (!address || !isConnected) {
setPerf(null); setTrades([]); setAccuracy(null); setPrivateLocked(false)
setTrades([]); setAccuracy(null); setPrivateLocked(false)
return
}
const sharedEnv = forcedEnv ?? getCachedViewEnvelope('view_user', address)
const perfEnv = getCachedViewEnvelope('view_performance', address) ?? sharedEnv
const tradesEnv = getCachedViewEnvelope('view_trades', address) ?? sharedEnv
if (aliveRef.current) setPrivateLocked(!perfEnv && !tradesEnv)
const tradesEnv = getCachedViewEnvelope('view_trades', address)
?? (forcedEnv ?? getCachedViewEnvelope('view_user', address))
if (aliveRef.current) setPrivateLocked(!tradesEnv)
try {
const [p, t, a] = await Promise.all([
perfEnv ? getPerformance(address, perfEnv).catch(() => null) : Promise.resolve(null),
tradesEnv ? getTrades(address, tradesEnv, 100, 1).catch(() => []) : Promise.resolve([]),
const [t, a] = await Promise.all([
tradesEnv ? getTrades(address, tradesEnv, 500, 1).catch(() => []) : Promise.resolve([]),
getSignalAccuracy().catch(() => null),
])
if (aliveRef.current) { setPerf(p); setTrades(t); setAccuracy(a) }
if (aliveRef.current) { setTrades(t); setAccuracy(a) }
} 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 avgTrade = pnls.length ? totalPnl / pnls.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)
const summaryPnl = period === '30d' && perf && trades.length >= filteredTrades.length
? perf.net_pnl_usd
: totalPnl
// One basis for every window: derive from the closed_at-filtered trades.
const maxDrawdown = calcDrawdownPct(filteredTrades)
const summaryPnl = totalPnl
const metrics = [
{ 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 [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(() => {
// 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(
'posts-500',
`posts-src-${tabSource}`,
3 * 60_000,
() => getPosts(500, 1),
() => getPosts(200, 1, tabSource),
fresh => setPosts(fresh),
)
.then(p => { setPosts(p); setLoadErr('') })
.catch(e => setLoadErr(e instanceof Error ? e.message : (isZh ? '信号加载失败' : 'Failed to load signals')))
.finally(() => setLoading(false))
}, [isZh])
}, [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(
() => posts.filter(p => (p.source || '') === tabSource),
[posts, tabSource],
@@ -270,7 +275,9 @@ function FundingPanel({
BTC perp funding · live
</div>
<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>
+4 -2
View File
@@ -29,8 +29,9 @@ async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
return res.json() as Promise<T>
}
export async function getPosts(limit = 20, page = 1): Promise<TrumpPost[]> {
return fetchJson<TrumpPost[]>(`/posts?limit=${limit}&page=${page}`)
export async function getPosts(limit = 20, page = 1, source?: string): Promise<TrumpPost[]> {
const q = `/posts?limit=${limit}&page=${page}` + (source ? `&source=${encodeURIComponent(source)}` : '')
return fetchJson<TrumpPost[]>(q)
}
export async function getPost(id: number): Promise<TrumpPost> {
@@ -459,6 +460,7 @@ export interface FundingSnapshot {
ok: boolean
error?: string
asset?: string
venue?: string
cadence_hours?: number
coverage_days?: number
latest_rate_pct?: number