mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
优化策略卡片结果加载性能
This commit is contained in:
+120
-12
@@ -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)}
|
||||
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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"]}
|
||||
@@ -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
@@ -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 }>(
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user