From af636146104f60583ab6fafd116cc7b559511c22 Mon Sep 17 00:00:00 2001
From: shy3130 <415333856@qq.com>
Date: Mon, 5 Oct 2026 15:16:16 +0800
Subject: [PATCH] =?UTF-8?q?perf(front,back):=20=E6=9F=A5=E8=AF=A2=E7=BC=93?=
=?UTF-8?q?=E5=AD=98=E6=8C=81=E4=B9=85=E5=8C=96=20+=20staleTime=20?=
=?UTF-8?q?=E5=88=86=E5=B1=82=20=E2=80=94=20=E9=87=8D=E5=BC=80=E5=BA=94?=
=?UTF-8?q?=E7=94=A8=E7=A7=92=E5=87=BA=E4=B8=8A=E6=AC=A1=E6=95=B0=E6=8D=AE?=
=?UTF-8?q?,=20=E4=B8=8D=E5=86=8D=E5=85=A8=E9=87=8F=E9=87=8D=E6=8B=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 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 干净
---
backend/app/services/regime_builder.py | 22 +++++++++---
frontend/package.json | 2 ++
frontend/pnpm-lock.yaml | 38 ++++++++++++++++++++
frontend/src/lib/queryPersist.ts | 49 ++++++++++++++++++++++++++
frontend/src/lib/useSharedQueries.ts | 4 +++
frontend/src/main.tsx | 17 +++++++--
frontend/src/pages/AbnormalMoves.tsx | 1 +
frontend/src/pages/Screener.tsx | 2 ++
frontend/src/pages/Signals.tsx | 4 +--
frontend/src/pages/Watchlist.tsx | 1 +
10 files changed, 131 insertions(+), 9 deletions(-)
create mode 100644 frontend/src/lib/queryPersist.ts
diff --git a/backend/app/services/regime_builder.py b/backend/app/services/regime_builder.py
index 322094db..5fc21b86 100644
--- a/backend/app/services/regime_builder.py
+++ b/backend/app/services/regime_builder.py
@@ -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()
diff --git a/frontend/package.json b/frontend/package.json
index 04666b56..355ec3fb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 67dcabb8..907fb257 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -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
diff --git a/frontend/src/lib/queryPersist.ts b/frontend/src/lib/queryPersist.ts
new file mode 100644
index 00000000..02856ecb
--- /dev/null
+++ b/frontend/src/lib/queryPersist.ts
@@ -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,
+ })
+}
diff --git a/frontend/src/lib/useSharedQueries.ts b/frontend/src/lib/useSharedQueries.ts
index 9734224e..34e7dce4 100644
--- a/frontend/src/lib/useSharedQueries.ts
+++ b/frontend/src/lib/useSharedQueries.ts
@@ -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,
})
}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 304b5605..996d386b 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -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(
-
+
-
+
,
)
}
diff --git a/frontend/src/pages/AbnormalMoves.tsx b/frontend/src/pages/AbnormalMoves.tsx
index 5393ce16..f767cd3e 100644
--- a/frontend/src/pages/AbnormalMoves.tsx
+++ b/frontend/src/pages/AbnormalMoves.tsx
@@ -644,6 +644,7 @@ function DeviationView({ onPreview }: {
queryKey: QK.watchlist,
queryFn: api.watchlistList,
enabled: watchlistOnly,
+ staleTime: 30_000,
})
const toggleEnabled = (v: boolean) => {
diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx
index 7f25ca05..79eabd0e 100644
--- a/frontend/src/pages/Screener.tsx
+++ b/frontend/src/pages/Screener.tsx
@@ -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 ?? []
diff --git a/frontend/src/pages/Signals.tsx b/frontend/src/pages/Signals.tsx
index eb075a82..945c6691 100644
--- a/frontend/src/pages/Signals.tsx
+++ b/frontend/src/pages/Signals.tsx
@@ -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('custom')
const [showForm, setShowForm] = useState(false)
diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx
index aed158c8..8b5c7891 100644
--- a/frontend/src/pages/Watchlist.tsx
+++ b/frontend/src/pages/Watchlist.tsx
@@ -785,6 +785,7 @@ export function Watchlist() {
const list = useQuery({
queryKey: QK.watchlist,
queryFn: api.watchlistList,
+ staleTime: 30_000, // 清单慢变 (增删后 mutation 会失效), 减少切页重拉
})
const groupList = useQuery({