perf(front,back): 查询缓存持久化 + staleTime 分层 — 重开应用秒出上次数据, 不再全量重拉

- 新增 lib/queryPersist.ts: PersistQueryClientProvider 接入 localStorage 持久化,
  白名单 13 个慢变查询族 (设置/偏好/能力矩阵/自选清单与分组/策略池/regime 全家/自定义信号),
  键首段精确匹配不误伤 watchlist-enriched 等实时行情族; 24h maxAge + 版本 buster;
  快变数据 (实时价/分钟K/告警) 刻意不持久化, 避免陈旧价格误导
- staleTime 分层: settings/preferences 30s, capability-matrix 5min, 自选清单 30s,
  策略池 60s, 自定义信号 60s — 切页不再对慢变数据反复请求
- regime_builder.load_regime_history 加 (path,mtime,size) 签名缓存: Regime 页
  三个端点原先每请求各自全量重读同一 parquet, 现共享一份, 写入后 mtime 失效

验证: pnpm build 通过; 浏览器实测持久化恢复路径 (注入缓存刷新后守卫直接放行,
不再整屏 loading); regime 相关 pytest 22 passed; ruff 零新增; git diff --check 干净
This commit is contained in:
shy3130
2026-10-05 15:16:16 +08:00
parent f3804215f9
commit af63614610
10 changed files with 131 additions and 9 deletions
+18 -4
View File
@@ -512,13 +512,27 @@ def regime_path(data_dir: Path) -> Path:
return data_dir / REGIME_DIR / "part.parquet"
# (path, mtime_ns, size) 签名缓存 — regime 三个端点每请求各读一次全量 parquet,
# 重算/导入改变文件后 mtime 变化自动失效; Polars 帧由调用方只读 (操作均为函数式)
_REGIME_HISTORY_CACHE: pl.DataFrame | None = None
_REGIME_HISTORY_SIG: tuple[str, int, int] | None = None
def load_regime_history(data_dir: Path) -> pl.DataFrame:
"""读取全部 regime 时序; 不存在返回空 DataFrame。"""
"""读取全部 regime 时序; 不存在返回空 DataFrame (带 mtime 签名缓存)。"""
global _REGIME_HISTORY_CACHE, _REGIME_HISTORY_SIG
p = regime_path(data_dir)
if not p.exists():
return pl.DataFrame()
try:
return pl.read_parquet(p)
sig = (str(p), p.stat().st_mtime_ns, p.stat().st_size)
except OSError:
return pl.DataFrame()
if _REGIME_HISTORY_CACHE is not None and sig == _REGIME_HISTORY_SIG:
return _REGIME_HISTORY_CACHE
try:
df = pl.read_parquet(p)
_REGIME_HISTORY_CACHE = df
_REGIME_HISTORY_SIG = sig
return df
except Exception as e: # noqa: BLE001
logger.warning("load_regime_history failed: %s", e)
return pl.DataFrame()
+2
View File
@@ -16,7 +16,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/inter": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@tanstack/query-sync-storage-persister": "^5.104.1",
"@tanstack/react-query": "^5.55.0",
"@tanstack/react-query-persist-client": "^5.104.1",
"@tanstack/react-virtual": "^3.13.12",
"clsx": "^2.1.1",
"echarts": "^5.5.0",
+38
View File
@@ -23,9 +23,15 @@ importers:
'@fontsource/jetbrains-mono':
specifier: ^5.3.0
version: 5.3.0
'@tanstack/query-sync-storage-persister':
specifier: ^5.104.1
version: 5.104.1
'@tanstack/react-query':
specifier: ^5.55.0
version: 5.100.11(react@18.3.1)
'@tanstack/react-query-persist-client':
specifier: ^5.104.1
version: 5.104.1(@tanstack/react-query@5.100.11(react@18.3.1))(react@18.3.1)
'@tanstack/react-virtual':
specifier: ^3.13.12
version: 3.14.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -559,6 +565,21 @@ packages:
'@tanstack/query-core@5.100.11':
resolution: {integrity: sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==}
'@tanstack/query-core@5.104.1':
resolution: {integrity: sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==}
'@tanstack/query-persist-client-core@5.104.1':
resolution: {integrity: sha512-ONiadXqe56iNrw6RESqKILpTIVG9ZMI/DageTQnrBDt/nZ6WiP4G13iIiMwJEsiO12gQ3Gychx8tcFIPw4k25g==}
'@tanstack/query-sync-storage-persister@5.104.1':
resolution: {integrity: sha512-eDsBMNds48+sjXILn0dQKu+uYwfh9tGIB+Rb0cKRdoxD22HHBMzJes5DJOhJVLUhBQbxtoFq+Hpha/Vohh14NQ==}
'@tanstack/react-query-persist-client@5.104.1':
resolution: {integrity: sha512-kU3YaqjkwEUzUUfPF0JVg56d6gJKWHaFVqOlQztwc4C2IT0P5sv/23orrSoQqjxKaUe6Ejw5IIQtA/G12uNmow==}
peerDependencies:
'@tanstack/react-query': ^5.104.1
react: ^18 || ^19
'@tanstack/react-query@5.100.11':
resolution: {integrity: sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==}
peerDependencies:
@@ -1765,6 +1786,23 @@ snapshots:
'@tanstack/query-core@5.100.11': {}
'@tanstack/query-core@5.104.1': {}
'@tanstack/query-persist-client-core@5.104.1':
dependencies:
'@tanstack/query-core': 5.104.1
'@tanstack/query-sync-storage-persister@5.104.1':
dependencies:
'@tanstack/query-core': 5.104.1
'@tanstack/query-persist-client-core': 5.104.1
'@tanstack/react-query-persist-client@5.104.1(@tanstack/react-query@5.100.11(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/query-persist-client-core': 5.104.1
'@tanstack/react-query': 5.100.11(react@18.3.1)
react: 18.3.1
'@tanstack/react-query@5.100.11(react@18.3.1)':
dependencies:
'@tanstack/query-core': 5.100.11
+49
View File
@@ -0,0 +1,49 @@
/**
* 查询缓存持久化 — 解决「每次打开应用都全量重新拉数据、等很久」。
*
* 仅白名单内的慢变查询族落地 localStorage: 重开应用时先渲染上一次的数据
* (SWR: 挂载后仍按各自 staleTime 后台刷新), 秒开有内容。快变行情类
* (watchlist-enriched 实时价 / 分钟K / 告警 / overview-market 等) 不持久化,
* 避免陈旧价格误导。白名单外的查询仍是纯内存缓存, 行为与原先完全一致。
*
* 键首段精确匹配 (不是前缀匹配): 'watchlist' 不会误命中 'watchlist-enriched'。
*/
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
/** 持久化结构版本: 序列化结构变化时递增, 旧缓存整体作废 */
export const PERSIST_BUSTER = 'v1'
/** 允许持久化的查询键首段 (对齐 lib/queryKeys.ts 各工厂首段与个别内联键) */
const PERSISTABLE_KEY_ROOTS = new Set([
'settings', // 应用设置 (OnboardingGuard 依赖; 恢复后打开不再整屏 logo)
'preferences', // 用户偏好
'capability-matrix', // 能力路由矩阵 (慢变)
'watchlist', // 自选清单 (不含 enriched/quotes 等行情族)
'watchlist-groups',
'screener-strategies', // 策略池清单
'regime-history', // 以下 regime 族一天最多变一次
'regime-states',
'regime-coverage',
'regime-phases',
'regime-mainline',
'custom-signals', // 自定义信号库
'custom-signals-options',
])
function isPersistableQueryKey(key: readonly unknown[]): boolean {
return typeof key[0] === 'string' && PERSISTABLE_KEY_ROOTS.has(key[0])
}
/** dehydrate 过滤: 只落地白名单族的成功查询 (默认规则已排除 pending/error) */
export function shouldPersistQuery(query: { queryKey: readonly unknown[]; state: { status: string } }): boolean {
return query.state.status === 'success' && isPersistableQueryKey(query.queryKey)
}
/** localStorage 同步 persister (throttle 2s 合并高频写) */
export function createAppPersister() {
return createSyncStoragePersister({
storage: window.localStorage,
key: 'tf-query-cache',
throttleTime: 2_000,
})
}
+4
View File
@@ -28,6 +28,8 @@ export function useCapabilityMatrix() {
return useQuery({
queryKey: QK.capabilityMatrix,
queryFn: api.capabilityMatrix,
// 矩阵仅在路由偏好/插件装卸时变化 (设置页会失效): 5 分钟新鲜度减少切页重拉
staleTime: 5 * 60_000,
})
}
@@ -36,6 +38,7 @@ export function useSettings() {
return useQuery({
queryKey: QK.settings,
queryFn: api.settings,
staleTime: 30_000,
})
}
@@ -44,6 +47,7 @@ export function usePreferences() {
return useQuery({
queryKey: QK.preferences,
queryFn: api.preferences,
staleTime: 30_000,
})
}
+14 -3
View File
@@ -1,8 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
import { QueryClient, QueryCache } from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { initializeFrontendExtensions } from './extensions/bootstrap'
import { createAppPersister, shouldPersistQuery, PERSIST_BUSTER } from './lib/queryPersist'
// 字体自托管 (@fontsource): 替代 rsms.me / Google Fonts 渲染阻塞外链,
// 内网/离线部署不再白屏等字体。权重覆盖 tailwind 全部用量 (300-900)。
import '@fontsource/inter/300.css'
@@ -59,9 +61,18 @@ async function bootstrap() {
const { router } = await import('./router')
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: createAppPersister(),
buster: PERSIST_BUSTER,
// 恢复超过 1 天的缓存直接丢弃 (慢变族一天内必然后台刷新过)
maxAge: 24 * 60 * 60 * 1000,
dehydrateOptions: { shouldDehydrateQuery: shouldPersistQuery },
}}
>
<RouterProvider router={router} />
</QueryClientProvider>
</PersistQueryClientProvider>
</React.StrictMode>,
)
}
+1
View File
@@ -644,6 +644,7 @@ function DeviationView({ onPreview }: {
queryKey: QK.watchlist,
queryFn: api.watchlistList,
enabled: watchlistOnly,
staleTime: 30_000,
})
const toggleEnabled = (v: boolean) => {
+2
View File
@@ -156,6 +156,7 @@ export function Screener() {
const strategies = useQuery({
queryKey: [...QK.screenerStrategies('all'), 'all'],
queryFn: () => api.screenerStrategies(undefined, 'all'),
staleTime: 60_000, // 策略池清单慢变 (与 Paper 同款), 减少切页重拉
})
// 激活策略自身的执行周期 (决定走缓存还是分钟实时跑)。
@@ -646,6 +647,7 @@ export function Screener() {
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: api.watchlistList,
staleTime: 30_000,
})
const watchlistSet = useMemo(() => {
const symbols = watchlist.data?.symbols ?? []
+2 -2
View File
@@ -38,8 +38,8 @@ export function Signals() {
function SignalsBody({ highlight }: { highlight: string }) {
const qc = useQueryClient()
const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions })
const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList, staleTime: 60_000 })
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions, staleTime: 60_000 })
const [activeSection, setActiveSection] = useState<SignalSection>('custom')
const [showForm, setShowForm] = useState(false)
+1
View File
@@ -785,6 +785,7 @@ export function Watchlist() {
const list = useQuery({
queryKey: QK.watchlist,
queryFn: api.watchlistList,
staleTime: 30_000, // 清单慢变 (增删后 mutation 会失效), 减少切页重拉
})
const groupList = useQuery({