From de51929a44a7cc3e6f25b8dfefda60fb418cf1bc Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Wed, 26 Aug 2026 12:55:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(mining):=20=E5=B8=82=E5=9C=BA=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E6=95=B0=E6=8D=AE=E7=BC=BA=E5=A4=B1=E6=97=B6=E6=8C=96?= =?UTF-8?q?=E6=8E=98=E5=90=AF=E5=8A=A8=E5=8D=B3=E5=A4=B1=E8=B4=A5=E5=B9=B6?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户在未计算市场环境的数据目录跑挖掘: 搜索阶段的 regime 评估被 evaluator 软捕获成 error 字符串, 整轮嵌套搜索照常跑完后, artifacts 阶段的 _regime_date_count 硬抛 ValueError, 计算全部浪费且只在最后 给出指引。 - mining_runtime: require_regime 时在因子面板就绪后立即用与 artifacts 相同的 fold 口径预构建一次 regime mask (required 区间取 全部外层 fold 测试窗并集), 数据为空/覆盖不完整即刻带指引消息终止 - MiningWorkbench: 启动前查询 /api/regime/latest, 未计算时侧栏显示 警告横幅(直达数据页)并在开始时 toast 阻止, 避免产生必败的运行记录 - 测试: 缺数据 fail-fast / 覆盖充足通过 / fold 窗口内缺口报错 三例 --- backend/app/backtest/mining_runtime.py | 30 +++++++ backend/tests/backtest/test_mining_runtime.py | 80 ++++++++++++++++++- .../src/pages/backtest/MiningWorkbench.tsx | 21 ++++- 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/backend/app/backtest/mining_runtime.py b/backend/app/backtest/mining_runtime.py index f351ccd..a04759d 100644 --- a/backend/app/backtest/mining_runtime.py +++ b/backend/app/backtest/mining_runtime.py @@ -522,6 +522,10 @@ def run_mining_runtime( "factors": len(request.factor_names), }) + if request.require_regime: + emit({"phase": "panel", "label": "校验市场环境数据", "done": 0, "total": 1}) + _validate_regime_availability(panel, request, data_dir) + _raise_if_cancelled(cancel_check) start_phase() matrix_started = time.perf_counter() @@ -1309,6 +1313,32 @@ def _fold_row( } +def _validate_regime_availability( + panel: pl.DataFrame, + request: RuntimeRequest, + data_dir: Path, +) -> None: + """搜索开始前 fail-fast 校验市场环境数据。 + + 环境分组评估 (strong/range/weak) 依赖 T-1 市场环境序列; 数据为空或覆盖不足时, + 若拖到 artifacts 阶段才在 _regime_date_count 中抛错, 整轮嵌套搜索的计算全部浪费。 + 这里用与 artifacts 相同的 fold 口径 (panel 标签) 预先构建一次 mask: + required 区间取所有外层 fold 测试窗的并集, 覆盖后续每个 fold 单独调用的要求, + 任何 ValueError (数据为空 / 覆盖不完整) 立即带指引消息终止运行。 + """ + labels = _date_labels(panel) + nested = generate_nested_folds(labels, request.mining_request.validation) + if not nested: + return + StrategyBacktestService._build_regime_mask( + labels, + _REGIME_FILTERS["strong"], + data_dir, + required_start=date.fromisoformat(nested[0].outer.test_start), + required_end=date.fromisoformat(nested[-1].outer.test_end), + ) + + def _regime_date_count( panel: pl.DataFrame, validation_fold, diff --git a/backend/tests/backtest/test_mining_runtime.py b/backend/tests/backtest/test_mining_runtime.py index b982700..7c13449 100644 --- a/backend/tests/backtest/test_mining_runtime.py +++ b/backend/tests/backtest/test_mining_runtime.py @@ -6,7 +6,11 @@ from types import SimpleNamespace import polars as pl import pytest -from app.backtest.mining import MiningCandidate +from app.backtest.mining import ( + MiningCandidate, + NestedValidationConfig, + generate_nested_folds, +) from app.backtest.mining_runtime import ( TrainingMetricProvider, _decode_runtime_request, @@ -14,6 +18,7 @@ from app.backtest.mining_runtime import ( _prepare_base_market, _rank_artifact_candidates, _regime_date_count, + _validate_regime_availability, attach_single_forward_return, ) from app.services import regime_builder @@ -312,3 +317,76 @@ def test_regime_date_count_uses_t_minus_one_market_labels(tmp_path) -> None: assert _regime_date_count(panel, fold, "strong", tmp_path) == 2 assert _regime_date_count(panel, fold, "weak", tmp_path) == 1 + + +def _small_validation() -> NestedValidationConfig: + return NestedValidationConfig( + outer_train_bars=10, + outer_test_bars=3, + outer_step_bars=5, + inner_train_bars=5, + inner_test_bars=2, + inner_step_bars=3, + purge_bars=1, + embargo_bars=1, + min_train_bars=3, + ) + + +def _regime_panel(n: int = 20) -> tuple[list[date], pl.DataFrame]: + labels = [date(2024, 1, 2) + timedelta(days=offset) for offset in range(n)] + return labels, pl.DataFrame({"date": labels}) + + +def _upsert_regime(tmp_path, labels, states: list[str]) -> None: + regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({ + "date": labels, + "state": states, + "score": [50] * len(labels), + })) + + +def test_validate_regime_availability_fails_fast_when_regime_data_missing( + tmp_path, +) -> None: + labels, panel = _regime_panel() + request = SimpleNamespace( + mining_request=SimpleNamespace(validation=_small_validation()), + ) + + with pytest.raises(ValueError, match="市场环境数据为空"): + _validate_regime_availability(panel, request, tmp_path) + + +def test_validate_regime_availability_passes_when_regime_covers_fold_windows( + tmp_path, +) -> None: + labels, panel = _regime_panel() + _upsert_regime( + tmp_path, + labels[:-1], + ["range"] * (len(labels) - 1), + ) + request = SimpleNamespace( + mining_request=SimpleNamespace(validation=_small_validation()), + ) + + _validate_regime_availability(panel, request, tmp_path) + + +def test_validate_regime_availability_reports_coverage_gaps_in_fold_windows( + tmp_path, +) -> None: + labels, panel = _regime_panel() + nested = generate_nested_folds( + [value.isoformat() for value in labels], _small_validation() + ) + gap_date = date.fromisoformat(nested[0].outer.test_start) + timedelta(days=1) + covered = [value for value in labels if value != gap_date] + _upsert_regime(tmp_path, covered, ["range"] * len(covered)) + request = SimpleNamespace( + mining_request=SimpleNamespace(validation=_small_validation()), + ) + + with pytest.raises(ValueError, match="市场环境数据覆盖不完整"): + _validate_regime_availability(panel, request, tmp_path) diff --git a/frontend/src/pages/backtest/MiningWorkbench.tsx b/frontend/src/pages/backtest/MiningWorkbench.tsx index d76f4be..c217ce5 100644 --- a/frontend/src/pages/backtest/MiningWorkbench.tsx +++ b/frontend/src/pages/backtest/MiningWorkbench.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useSearchParams } from 'react-router-dom' +import { Link, useSearchParams } from 'react-router-dom' import { AlertTriangle, CheckCircle2, @@ -303,6 +303,7 @@ export function MiningWorkbench() { enabled: validDateRange, staleTime: 30_000, }) + const regimeLatestQuery = useQuery({ queryKey: QK.regimeLatest, queryFn: api.regimeLatest, staleTime: 60_000 }) const configQuery = useQuery({ queryKey: QK.miningConfig, queryFn: api.miningConfig }) useEffect(() => { @@ -454,6 +455,18 @@ export function MiningWorkbench() { ) return } + if (regimeLatestQuery.isPending || regimeLatestQuery.isFetching) { + toast('正在核验市场环境数据,请稍候', 'error') + return + } + if (regimeLatestQuery.isError || !regimeLatestQuery.data) { + toast('无法核验市场环境数据,请检查数据状态后重试', 'error') + return + } + if (!regimeLatestQuery.data.row) { + toast('尚未计算市场环境数据:挖掘含市场环境分组评估,请先在数据页完成市场环境计算', 'error') + return + } const commissionBps = parseBoundedNumber(draft.commissionBps, '佣金', 0, 500) const stampTaxBps = parseBoundedNumber(draft.stampTaxBps, '印花税', 0, 500) const slippageBps = parseBoundedNumber(draft.slippageBps, '滑点', 0, 1000) @@ -567,6 +580,12 @@ export function MiningWorkbench() { )} + {regimeLatestQuery.data && !regimeLatestQuery.data.row && ( + + 尚未计算市场环境数据 — 挖掘含市场环境分组评估,缺少时启动即校验失败。前往数据页完成市场环境计算 → + + )} +
因子目录