mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
fix(mining): 市场环境数据缺失时挖掘启动即失败并前置提示
用户在未计算市场环境的数据目录跑挖掘: 搜索阶段的 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 窗口内缺口报错 三例
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{regimeLatestQuery.data && !regimeLatestQuery.data.row && (
|
||||
<Link to="/data" className="block rounded-btn border border-warning/40 bg-warning/5 px-2 py-1.5 text-[9px] leading-4 text-warning transition-colors hover:border-warning/70">
|
||||
尚未计算市场环境数据 — 挖掘含市场环境分组评估,缺少时启动即校验失败。<span className="underline underline-offset-2">前往数据页完成市场环境计算 →</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<section className="border-t border-border pt-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold text-secondary"><FlaskConical className="h-3 w-3" />因子目录</span>
|
||||
|
||||
Reference in New Issue
Block a user