优化策略卡片结果加载性能

This commit is contained in:
shy3130
2026-07-20 13:01:03 +08:00
parent 01d26da52c
commit d79d3f9898
8 changed files with 425 additions and 153 deletions
+120 -12
View File
@@ -321,18 +321,8 @@ def run_preset(req: PresetRequest, request: Request):
return _result_with_ext(safe_data, ext_values)
@router.get("/cached")
def get_cached(
request: Request,
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
):
"""读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果。
- 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。
- 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出,
不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。
被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。
"""
def _cached_with_realtime(request: Request) -> dict:
"""读取盘后缓存,并用监控引擎的实时结果覆盖同策略。"""
data_dir = request.app.state.repo.store.data_dir
cached = strategy_cache.read_cache(data_dir)
if cached is None:
@@ -351,6 +341,23 @@ def get_cached(
import time as _time
cached["updated_at"] = int(_time.time() * 1000)
return cached
@router.get("/cached")
def get_cached(
request: Request,
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
):
"""读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果。
- 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。
- 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出,
不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。
被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。
"""
cached = _cached_with_realtime(request)
# 无任何数据 (盘后缓存空 + 无实时结果) → 返回空标记, 前端据此提示
if not cached.get("results") and cached.get("as_of") is None:
return {"as_of": None, "results": {}, "updated_at": None}
@@ -359,6 +366,98 @@ def get_cached(
return _cache_payload_with_ext(cached, ext_values)
@router.get("/cached-summary")
def get_cached_summary(request: Request):
"""返回策略卡片所需的轻量摘要,不序列化股票明细。"""
cached = _cached_with_realtime(request)
results = cached.get("results") or {}
summary = {
sid: {
"total": int(result.get("total") or 0),
"as_of": result.get("as_of"),
}
for sid, result in results.items()
if isinstance(result, dict)
}
cached_as_of = cached.get("as_of")
ever_rows = cached.get("today_ever_rows") or {}
ever_counts = {}
for sid, result in results.items():
if not isinstance(result, dict) or result.get("as_of") != cached_as_of:
continue
current_symbols = {
str(row["symbol"])
for row in result.get("rows") or []
if isinstance(row, dict) and row.get("symbol")
}
ever_counts[sid] = len(set((ever_rows.get(sid) or {}).keys()) | current_symbols)
return {
"as_of": cached_as_of,
"results": summary,
"today_ever_counts": ever_counts,
"updated_at": cached.get("updated_at"),
}
@router.get("/cached-result/{strategy_id}")
def get_cached_result(
strategy_id: str,
request: Request,
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
):
"""按需返回单个策略的完整明细及其今日失效行。"""
cached = _cached_with_realtime(request)
raw_result = (cached.get("results") or {}).get(strategy_id)
if not isinstance(raw_result, dict):
return {
"result": None,
"today_ever_rows": None,
"strategy_ids_by_symbol": {},
"updated_at": cached.get("updated_at"),
}
ext_values = _load_ext_value_maps(request.app.state.repo, ext_columns)
result = {
"as_of": raw_result.get("as_of"),
"strategy": strategy_id,
"rows": _rows_with_ext(raw_result.get("rows") or [], ext_values),
"total": int(raw_result.get("total") or 0),
"elapsed_ms": 0.0,
}
ever_rows = None
if cached.get("as_of") == result["as_of"]:
strategy_ever_rows = (cached.get("today_ever_rows") or {}).get(strategy_id)
if isinstance(strategy_ever_rows, dict):
ever_rows = {
symbol: _row_with_ext(row, ext_values, symbol=symbol)
for symbol, row in strategy_ever_rows.items()
if isinstance(row, dict)
}
selected_symbols = {
str(row["symbol"])
for row in raw_result.get("rows") or []
if isinstance(row, dict) and row.get("symbol")
}
strategy_ids_by_symbol: dict[str, list[str]] = {symbol: [] for symbol in selected_symbols}
for sid, cached_result in (cached.get("results") or {}).items():
if not isinstance(cached_result, dict) or cached_result.get("as_of") != result["as_of"]:
continue
for row in cached_result.get("rows") or []:
symbol = str(row.get("symbol")) if isinstance(row, dict) and row.get("symbol") else None
if symbol in strategy_ids_by_symbol:
strategy_ids_by_symbol[symbol].append(sid)
return {
"result": result,
"today_ever_rows": ever_rows,
"strategy_ids_by_symbol": strategy_ids_by_symbol,
"updated_at": cached.get("updated_at"),
}
@router.get("/market-snapshot")
def market_snapshot(request: Request):
"""最新全市场轻量行情快照,供板块/概念聚合分析使用。"""
@@ -485,6 +584,15 @@ def run_all(request: Request, body: Optional[dict] = None):
except Exception: # noqa: BLE001
pass
if body.get("summary_only"):
return {
"as_of": str(as_of),
"results": {
sid: {"total": result["total"], "as_of": result["as_of"]}
for sid, result in results.items()
},
}
ext_values = _load_ext_value_maps(repo, body.get("ext_columns"))
return {"as_of": str(as_of), "results": _results_with_ext(results, ext_values)}
+8 -3
View File
@@ -138,6 +138,11 @@ def _write_cache_locked(
old_as_of = old.get("as_of") if old else None
old_ever_rows: dict[str, dict[str, dict]] = old.get("today_ever_rows", {}) if old else {}
if old_as_of == as_of:
merged_results = {**(old.get("results") or {}), **results}
else:
merged_results = results
# 当前命中的行数据 → symbol 映射
current_row_maps: dict[str, dict[str, dict]] = {}
for sid, r in results.items():
@@ -167,7 +172,7 @@ def _write_cache_locked(
payload = {
"as_of": as_of,
"results": results,
"results": merged_results,
"today_ever_matched": today_ever_matched,
"today_ever_rows": today_ever_rows,
"enriched_mtime": enriched_mtime,
@@ -178,8 +183,8 @@ def _write_cache_locked(
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, default=_json_default), encoding="utf-8")
os.replace(tmp, path)
total_rows = sum(len(r.get("rows", [])) for r in results.values())
total_rows = sum(len(r.get("rows", [])) for r in merged_results.values())
total_ever = sum(len(v) for v in today_ever_matched.values())
logger.info("策略缓存已写入: %s, %d 策略, %d 命中, %d 曾命中", as_of, len(results), total_rows, total_ever)
logger.info("策略缓存已写入: %s, %d 策略, %d 命中, %d 曾命中", as_of, len(merged_results), total_rows, total_ever)
except Exception as e: # noqa: BLE001
logger.warning("写入策略缓存失败: %s", e)
@@ -129,3 +129,27 @@ def test_batch_run_passes_saved_params_to_strategy_engine(monkeypatch, tmp_path)
assert context_call["overrides_map"] == expected_overrides
assert run_all_call["params_map"] == expected_params
assert run_all_call["overrides_map"] == expected_overrides
def test_batch_summary_response_still_writes_full_cache(monkeypatch, tmp_path):
engine = _CapturingStrategyEngine()
request = _api_request(tmp_path, engine)
_install_api_fakes(monkeypatch)
written = []
monkeypatch.setattr(screener_api.strategy_config, "list_overrides", lambda *_args: {})
monkeypatch.setattr(screener_api.strategy_cache, "write_cache", lambda *args: written.append(args))
payload = screener_api.run_all(
request,
body={
"as_of": "2026-07-15",
"strategy_ids": ["builtin_strategy"],
"summary_only": True,
},
)
assert payload == {
"as_of": "2026-07-15",
"results": {"builtin_strategy": {"total": 0, "as_of": "2026-07-15"}},
}
assert written[0][2]["builtin_strategy"]["rows"] == []
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from types import SimpleNamespace
from app.api import screener as screener_api
class _MonitorEngine:
def __init__(self, results=None):
self.results = results or {}
def latest_strategy_results(self):
return self.results
def _request(tmp_path, monitor_results=None):
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
state = SimpleNamespace(repo=repo, monitor_engine=_MonitorEngine(monitor_results))
return SimpleNamespace(app=SimpleNamespace(state=state))
def test_cached_summary_omits_rows_and_counts_realtime_expirations(monkeypatch, tmp_path):
cached = {
"as_of": "2026-07-20",
"results": {
"strategy_a": {
"as_of": "2026-07-20",
"total": 2,
"rows": [{"symbol": "000001.SZ"}, {"symbol": "000002.SZ"}],
},
},
"today_ever_rows": {
"strategy_a": {
"000001.SZ": {"symbol": "000001.SZ"},
"600000.SH": {"symbol": "600000.SH"},
},
},
"updated_at": 1,
}
realtime = {
"strategy_a": {
"as_of": "2026-07-20",
"total": 2,
"rows": [{"symbol": "000002.SZ"}, {"symbol": "300001.SZ"}],
},
}
monkeypatch.setattr(screener_api.strategy_cache, "read_cache", lambda *_args: cached)
payload = screener_api.get_cached_summary(_request(tmp_path, realtime))
assert payload["results"] == {"strategy_a": {"total": 2, "as_of": "2026-07-20"}}
assert payload["today_ever_counts"] == {"strategy_a": 4}
assert "rows" not in payload["results"]["strategy_a"]
def test_cached_result_returns_only_requested_rows_with_ext_and_strategy_membership(monkeypatch, tmp_path):
cached = {
"as_of": "2026-07-20",
"results": {
"strategy_a": {
"as_of": "2026-07-20",
"total": 1,
"rows": [{"symbol": "000001.SZ"}],
},
"strategy_b": {
"as_of": "2026-07-20",
"total": 2,
"rows": [{"symbol": "000001.SZ"}, {"symbol": "600000.SH"}],
},
},
"today_ever_rows": {
"strategy_a": {
"000001.SZ": {"symbol": "000001.SZ"},
"000002.SZ": {"symbol": "000002.SZ"},
},
},
"updated_at": 1,
}
monkeypatch.setattr(screener_api.strategy_cache, "read_cache", lambda *_args: cached)
monkeypatch.setattr(
screener_api,
"_load_ext_value_maps",
lambda *_args: {"concept.concept": {"000001.SZ": "银行", "000002.SZ": "科技"}},
)
payload = screener_api.get_cached_result(
"strategy_a",
_request(tmp_path),
ext_columns="concept.concept",
)
assert payload["result"]["strategy"] == "strategy_a"
assert payload["result"]["rows"] == [{"symbol": "000001.SZ", "concept.concept": "银行"}]
assert payload["today_ever_rows"]["000002.SZ"]["concept.concept"] == "科技"
assert payload["strategy_ids_by_symbol"] == {"000001.SZ": ["strategy_a", "strategy_b"]}
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from app.services import strategy_cache
def _result(*symbols: str) -> dict:
return {
"total": len(symbols),
"as_of": "2026-07-20",
"rows": [{"symbol": symbol, "close": index + 1.0} for index, symbol in enumerate(symbols)],
}
def test_same_day_partial_writes_merge_strategy_results(tmp_path):
strategy_cache.write_cache(tmp_path, "2026-07-20", {"strategy_a": _result("000001.SZ")})
strategy_cache.write_cache(tmp_path, "2026-07-20", {"strategy_b": _result("600000.SH")})
cached = strategy_cache.read_cache(tmp_path)
assert set(cached["results"]) == {"strategy_a", "strategy_b"}
assert cached["results"]["strategy_a"]["rows"][0]["symbol"] == "000001.SZ"
assert cached["results"]["strategy_b"]["rows"][0]["symbol"] == "600000.SH"
def test_same_day_update_replaces_only_target_strategy_and_keeps_ever_rows(tmp_path):
strategy_cache.write_cache(tmp_path, "2026-07-20", {
"strategy_a": _result("000001.SZ"),
"strategy_b": _result("600000.SH"),
})
strategy_cache.write_cache(tmp_path, "2026-07-20", {"strategy_a": _result("000002.SZ")})
cached = strategy_cache.read_cache(tmp_path)
assert [row["symbol"] for row in cached["results"]["strategy_a"]["rows"]] == ["000002.SZ"]
assert [row["symbol"] for row in cached["results"]["strategy_b"]["rows"]] == ["600000.SH"]
assert set(cached["today_ever_rows"]["strategy_a"]) == {"000001.SZ", "000002.SZ"}
def test_new_date_resets_results_and_ever_rows(tmp_path):
strategy_cache.write_cache(tmp_path, "2026-07-20", {"strategy_a": _result("000001.SZ")})
next_day = _result("600000.SH")
next_day["as_of"] = "2026-07-21"
strategy_cache.write_cache(tmp_path, "2026-07-21", {"strategy_b": next_day})
cached = strategy_cache.read_cache(tmp_path)
assert cached["as_of"] == "2026-07-21"
assert set(cached["results"]) == {"strategy_b"}
assert set(cached["today_ever_rows"]) == {"strategy_b"}
+30 -3
View File
@@ -300,6 +300,25 @@ export interface ScreenerResult {
elapsed_ms: number
}
export interface ScreenerResultSummary {
total: number
as_of: string
}
export interface ScreenerCachedSummary {
as_of: string | null
results: Record<string, ScreenerResultSummary>
today_ever_counts: Record<string, number>
updated_at: number | null
}
export interface ScreenerCachedResult {
result: ScreenerResult | null
today_ever_rows: Record<string, any> | null
strategy_ids_by_symbol: Record<string, string[]>
updated_at: number | null
}
export interface MarketSnapshotRow {
symbol: string
name?: string | null
@@ -1399,9 +1418,17 @@ export const api = {
method: 'POST',
body: JSON.stringify({ conditions, order_by: orderBy, limit, pool, ext_columns: extColumns || null, asset_type: assetType }),
}),
screenerRunAll: (asOf?: string, strategyIds?: string[], extColumns?: string, assetType: 'stock' | 'etf' = 'stock') =>
request<{ as_of: string | null; results: Record<string, { total: number; as_of: string; rows: any[] }> }>(
'/api/screener/run_all', { method: 'POST', body: JSON.stringify({ as_of: asOf ?? null, strategy_ids: strategyIds ?? null, ext_columns: extColumns || null, asset_type: assetType, timeframe: '1d' }) },
screenerRunAll: (asOf?: string, strategyIds?: string[], assetType: 'stock' | 'etf' = 'stock') =>
request<{ as_of: string | null; results: Record<string, ScreenerResultSummary> }>(
'/api/screener/run_all', { method: 'POST', body: JSON.stringify({ as_of: asOf ?? null, strategy_ids: strategyIds ?? null, asset_type: assetType, timeframe: '1d', summary_only: true }) },
),
screenerCachedSummary: () =>
request<ScreenerCachedSummary>('/api/screener/cached-summary'),
screenerCachedResult: (strategyId: string, extColumns?: string) =>
request<ScreenerCachedResult>(
extColumns
? `/api/screener/cached-result/${encodeURIComponent(strategyId)}?ext_columns=${encodeURIComponent(extColumns)}`
: `/api/screener/cached-result/${encodeURIComponent(strategyId)}`,
),
screenerCached: (extColumns?: string) =>
request<{ as_of: string | null; results: Record<string, { total: number; as_of: string; rows: any[] }>; today_ever_matched: Record<string, string[]> | null; today_ever_rows: Record<string, Record<string, any>> | null; updated_at: number | null }>(
+3 -1
View File
@@ -34,7 +34,9 @@ export const QK = {
// Screener
screener: ['screener'] as const,
screenerStrategies: (assetType: string = 'stock') => ['screener-strategies', assetType] as const,
screenerCached: (ext?: string) => ['screener-cached', ext] as const,
screenerCachedSummary: ['screener-cached', 'summary'] as const,
screenerCachedResult: (strategyId: string, asOf?: string, ext?: string) => ['screener-cached', 'strategy', strategyId, asOf ?? '', ext ?? ''] as const,
screenerCached: (asOf?: string, ext?: string) => ['screener-cached', 'all', asOf ?? '', ext ?? ''] as const,
screenerKlineBatch: (symbols: string) => ['screener-kline-batch', symbols] as const,
marketSnapshot: ['market-snapshot'] as const,
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
+96 -134
View File
@@ -74,6 +74,7 @@ export function Screener() {
const [filter, setFilter] = useState<ScreenerFilterType>(defaultFilter)
const filterMap = useRef<Map<string, ScreenerFilterType>>(new Map())
const runAllDateRef = useRef<string | null>(null)
const qc = useQueryClient()
// 结果列配置 — 默认内置列,异步合并后端/localStorage 偏好
const [columns, setColumns] = useState<ColumnConfig[]>([...SCREENER_BUILTIN_COLUMNS])
@@ -123,10 +124,26 @@ export function Screener() {
queryFn: () => api.screenerStrategies(assetType),
})
// 策略结果缓存 — 文件读取,SSE invalidation 自动刷新
const cachedQuery = useQuery({
queryKey: QK.screenerCached(extColumnsParam),
// 卡片首屏只读取轻量摘要;明细在点击策略或“全部”时按需加载。
const summaryQuery = useQuery({
queryKey: QK.screenerCachedSummary,
queryFn: api.screenerCachedSummary,
enabled: assetType === 'stock',
})
const fullCachedQuery = useQuery({
queryKey: QK.screenerCached(asOf, extColumnsParam),
queryFn: () => api.screenerCached(extColumnsParam || undefined),
enabled: assetType === 'stock' && showAll,
})
const singleCachedQuery = useQuery({
queryKey: QK.screenerCachedResult(activeStrategy ?? '', asOf, extColumnsParam),
queryFn: () => api.screenerCachedResult(activeStrategy!, extColumnsParam || undefined),
enabled: assetType === 'stock'
&& !showAll
&& !!activeStrategy
&& summaryQuery.data?.results[activeStrategy]?.as_of === asOf,
})
const dataStatus = useDataStatus({ staleTime: 0 })
@@ -184,96 +201,81 @@ export function Screener() {
api.screenerRunAll(
date,
strategyIds ?? visiblePool,
extColumnsParam || undefined,
assetType,
),
onSuccess: (data) => {
if (data.as_of) setAsOf(data.as_of)
const counts: Record<string, number> = {}
for (const [id, item] of Object.entries(data.results)) {
counts[id] = item.total
}
setHitCounts(prev => ({ ...prev, ...counts }))
qc.invalidateQueries({ queryKey: ['screener-cached'] })
},
})
const applyRunAllResult = useCallback((strategyId: string, date: string, data = runAll.data) => {
const cached = data?.results?.[strategyId]
if (!cached || cached.as_of !== date) return false
const missingStrategyIds = useMemo(
() => visiblePool.filter(id => summaryQuery.data?.results[id]?.as_of !== asOf),
[visiblePool, summaryQuery.data, asOf],
)
const cacheCoversPool = visiblePool.length > 0 && missingStrategyIds.length === 0
setResult({
as_of: cached.as_of,
strategy: strategyId,
rows: cached.rows,
total: cached.total,
elapsed_ms: 0,
})
setHitCounts(prev => ({ ...prev, [strategyId]: cached.total }))
return true
}, [runAll.data])
// 缓存是否覆盖当前策略池
const cacheCoversPool = useMemo(() => {
if (!cachedQuery.data?.as_of || cachedQuery.data.as_of !== asOf) return false
if (!cachedQuery.data.results) return false
return visiblePool.length > 0 && visiblePool.every(id => id in cachedQuery.data!.results)
}, [cachedQuery.data, asOf, visiblePool])
// 统一数据源: 缓存优先,runAll fallback
const effectiveResults = useMemo(() => {
if (cacheCoversPool) return cachedQuery.data!.results
return runAll.data?.results ?? null
}, [cacheCoversPool, cachedQuery.data, runAll.data])
// 从 effectiveResults 同步 hitCounts + expiredCounts
// 摘要只同步当前日期的卡片数量,避免旧日期缓存短暂显示成当前结果。
useEffect(() => {
if (!effectiveResults) return
if (!summaryQuery.data || !asOf) return
const counts: Record<string, number> = {}
for (const [id, r] of Object.entries(effectiveResults)) {
const expired: Record<string, number> = {}
for (const [id, r] of Object.entries(summaryQuery.data.results)) {
if (r.as_of !== asOf) continue
counts[id] = r.total
const everCount = summaryQuery.data.today_ever_counts[id] ?? r.total
const expiredCount = Math.max(everCount - r.total, 0)
if (expiredCount > 0) expired[id] = expiredCount
}
setHitCounts(counts)
setExpiredCounts(expired)
}, [summaryQuery.data, asOf])
// 从缓存数据计算失效数 (ever_matched - current)
const everMatched = cachedQuery.data?.today_ever_matched
if (everMatched) {
const expired: Record<string, number> = {}
for (const [id, symbols] of Object.entries(everMatched) as [string, string[]][]) {
const currentRows = effectiveResults[id]?.rows ?? []
const currentSet = new Set(currentRows.map((r: any) => r.symbol))
const expiredCount = symbols.filter((s: string) => !currentSet.has(s)).length
if (expiredCount > 0) expired[id] = expiredCount
}
setExpiredCounts(expired)
// 当前单策略缓存更新后同步明细;参数保存的强制重算结果仍由 run 直接覆盖。
useEffect(() => {
const cached = singleCachedQuery.data?.result
if (!cached || showAll || cached.strategy !== activeStrategy || cached.as_of !== asOf) return
setResult(cached)
if (activeStrategy) {
setHitCounts(prev => ({ ...prev, [activeStrategy]: cached.total }))
}
}, [singleCachedQuery.data, showAll, activeStrategy, asOf])
// 如果有激活策略,同步当前 result(扩展列变化时也会刷新行数据)
if (activeStrategy && effectiveResults[activeStrategy]) {
const r = effectiveResults[activeStrategy]
setResult(prev => {
if (prev?.strategy === activeStrategy && prev.as_of === r.as_of && prev.rows === r.rows && prev.total === r.total) return prev
return {
as_of: r.as_of,
strategy: activeStrategy,
rows: r.rows,
total: r.total,
elapsed_ms: 0,
}
})
}
}, [effectiveResults, cachedQuery.data, activeStrategy])
const effectiveResults = useMemo(() => {
if (fullCachedQuery.data?.as_of !== asOf) return null
const entries = Object.entries(fullCachedQuery.data.results)
.filter(([, item]) => item.as_of === asOf)
return Object.fromEntries(entries)
}, [fullCachedQuery.data, asOf])
// symbol → 所属策略列表 (来自 effectiveResults)
// symbol → 所属策略列表。单策略接口同时返回轻量归属映射,保留策略列原有展示。
const symbolStrategyMap = useMemo(() => {
const map = new Map<string, string[]>()
if (!effectiveResults) return map
for (const [sid, r] of Object.entries(effectiveResults)) {
for (const row of r.rows) {
const arr = map.get(row.symbol)
if (arr) {
arr.push(sid)
} else {
map.set(row.symbol, [sid])
if (showAll) {
for (const [sid, r] of Object.entries(effectiveResults ?? {})) {
for (const row of r.rows) {
const arr = map.get(row.symbol)
if (arr) arr.push(sid)
else map.set(row.symbol, [sid])
}
}
return map
}
for (const [symbol, ids] of Object.entries(singleCachedQuery.data?.strategy_ids_by_symbol ?? {})) {
map.set(symbol, ids)
}
if (activeStrategy && result) {
for (const row of result.rows) {
if (!map.has(row.symbol)) map.set(row.symbol, [activeStrategy])
}
}
return map
}, [effectiveResults])
}, [showAll, effectiveResults, singleCachedQuery.data, activeStrategy, result])
// "全部" 模式: 合并所有策略的去重个股
const allRows = useMemo(() => {
@@ -291,22 +293,15 @@ export function Screener() {
return merged
}, [effectiveResults])
// 计算失效行: 在 today_ever_rows 中但不在当前 results 中
const expiredRowsMap = useMemo(() => {
const map = new Map<string, any[]>() // strategyId → expired rows
const everRows = cachedQuery.data?.today_ever_rows
if (!everRows || !effectiveResults) return map
for (const [sid, symMap] of Object.entries(everRows) as [string, Record<string, any>][]) {
const currentRows = effectiveResults[sid]?.rows ?? []
const currentSymbols = new Set(currentRows.map((r: any) => r.symbol))
const expired = Object.entries(symMap)
.filter(([sym]) => !currentSymbols.has(sym))
.map(([, row]) => ({ ...row, _expired: true }))
if (expired.length > 0) map.set(sid, expired)
}
return map
}, [cachedQuery.data, effectiveResults])
// 计算当前策略的失效行: 今日曾命中但当前已不命中。
const expiredRows = useMemo(() => {
const everRows = singleCachedQuery.data?.today_ever_rows
if (!everRows || !result || result.as_of !== asOf) return []
const currentSymbols = new Set(result.rows.map((row: any) => row.symbol))
return Object.entries(everRows)
.filter(([symbol]) => !currentSymbols.has(symbol))
.map(([, row]) => ({ ...row, _expired: true }))
}, [singleCachedQuery.data, result, asOf])
// 表头排序(受控):用户点击列则按该列;未点时下方按评分默认降序
const { sort, toggle, sortRows } = useTableSort()
@@ -327,13 +322,12 @@ export function Screener() {
// 追加当前策略的失效行 (灰色)
if (!showAll && activeStrategy) {
const expired = expiredRowsMap.get(activeStrategy) ?? []
if (expired.length > 0) {
return [...mainRows, ...expired]
if (expiredRows.length > 0) {
return [...mainRows, ...expiredRows]
}
}
return mainRows
}, [showAll, allRows, filteredRows, filter, activeStrategy, strategyLimits, expiredRowsMap, sort, sortRows, columns])
}, [showAll, allRows, filteredRows, filter, activeStrategy, strategyLimits, expiredRows, sort, sortRows, columns])
// 日k列是否启用 → 决定是否加载批量 kline 数据
const candleColumn = useMemo(() =>
@@ -406,8 +400,8 @@ export function Screener() {
useEffect(() => {
// ETF 模式无股票盘后缓存/ runAll, 单策略走实时单跑, 不触发 runAll
if (assetType !== 'stock') return
if (!asOf || !strategies.data?.presets?.length || runAll.isPending || visiblePool.length === 0) return
const runKey = `${asOf}|${visiblePool.join(',')}|${extColumnsParam}`
if (!asOf || !strategies.data?.presets?.length || !summaryQuery.isSuccess || runAll.isPending || visiblePool.length === 0) return
const runKey = `${asOf}|${visiblePool.join(',')}`
if (runAllDateRef.current === runKey) return
// 缓存已覆盖当前策略池 → 秒加载, 不触发 runAll
if (cacheCoversPool) {
@@ -417,14 +411,8 @@ export function Screener() {
// 未覆盖: 受系统开关控制
if (!screenerAutoRun) return
runAllDateRef.current = runKey
runAll.mutate({ date: asOf }, {
onSuccess: (data) => {
if (activeStrategy) applyRunAllResult(activeStrategy, asOf, data)
},
})
}, [asOf, strategies.data, visiblePool, extColumnsParam, cacheCoversPool, screenerAutoRun, activeStrategy, applyRunAllResult])
const qc = useQueryClient()
runAll.mutate({ date: asOf, strategyIds: missingStrategyIds })
}, [asOf, strategies.data, summaryQuery.isSuccess, visiblePool, cacheCoversPool, missingStrategyIds, screenerAutoRun, assetType, runAll.isPending])
const run = useMutation({
mutationFn: ({ id, date }: { id: string; date: string }) =>
@@ -433,9 +421,7 @@ export function Screener() {
setResult(data)
// 同步更新卡片上的命中数
setHitCounts(prev => ({ ...prev, [vars.id]: data.total }))
// 单策略重跑后, 后端 _update_cache_strategy 已更新该策略的缓存条目;
// 这里 invalidate screenerCached 让前端缓存同步, 避免点卡片时 handleRun
// 仍读到旧的 effectiveResults (改参数后刷新会回退到旧个数的根因)
// 单策略重跑后刷新摘要和当前按需明细,避免参数保存后回退到旧缓存。
qc.invalidateQueries({ queryKey: ['screener-cached'] })
},
})
@@ -444,43 +430,23 @@ export function Screener() {
handleStrategySwitch(s.id)
setActiveStrategy(s.id)
setShowAll(false)
if (result?.strategy !== s.id || result.as_of !== asOf) setResult(null)
// ETF 模式: 无股票盘后缓存, 始终实时单跑。
// 传空日期让后端用 ETF 自己的最新交易日 (asOf 跟随的是股票 enriched, 两者可能不同日)。
if (assetType !== 'stock') {
run.mutate({ id: s.id, date: '' })
return
}
// 优先从 effectiveResults (缓存 + runAll) 取数据
const r = effectiveResults?.[s.id]
if (r && r.as_of === asOf) {
setResult({
as_of: r.as_of,
strategy: s.id,
rows: r.rows,
total: r.total,
elapsed_ms: 0,
})
setHitCounts(prev => ({ ...prev, [s.id]: r.total }))
return
}
// Fall back to runAll data or single run
if (!applyRunAllResult(s.id, asOf)) {
run.mutate({ id: s.id, date: asOf })
}
// 摘要命中时由 singleCachedQuery 按需加载明细;缺失时才单独计算。
if (summaryQuery.data?.results[s.id]?.as_of === asOf || runAll.isPending) return
run.mutate({ id: s.id, date: asOf })
}
// 日期变化时,重新跑全部策略命中数 + 当前激活策略
// 日期变化交给统一 effect 计算一次,避免这里与 effect 重复请求。
const handleDateChange = (newDate: string) => {
setAsOf(newDate)
runAllDateRef.current = `${newDate}|${visiblePool.join(',')}|${extColumnsParam}`
runAll.mutate({ date: newDate }, {
onSuccess: (data) => {
if (activeStrategy) applyRunAllResult(activeStrategy, newDate, data)
},
})
if (activeStrategy) {
setResult(null)
}
runAllDateRef.current = null
setResult(null)
}
const minDate = dataStatus.data?.enriched?.earliest_date ?? ''
@@ -969,10 +935,6 @@ export function Screener() {
pool={pool}
onConfirm={(newPool) => {
reorderPool(newPool)
if (asOf) {
runAllDateRef.current = ''
runAll.mutate({ date: asOf, strategyIds: newPool })
}
}}
onClose={() => setShowPoolDialog(false)}
/>