mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
用户在未计算市场环境的数据目录跑挖掘: 搜索阶段的 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 窗口内缺口报错 三例
393 lines
12 KiB
Python
393 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
from types import SimpleNamespace
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.backtest.mining import (
|
|
MiningCandidate,
|
|
NestedValidationConfig,
|
|
generate_nested_folds,
|
|
)
|
|
from app.backtest.mining_runtime import (
|
|
TrainingMetricProvider,
|
|
_decode_runtime_request,
|
|
_load_compact_factor_panel,
|
|
_prepare_base_market,
|
|
_rank_artifact_candidates,
|
|
_regime_date_count,
|
|
_validate_regime_availability,
|
|
attach_single_forward_return,
|
|
)
|
|
from app.services import regime_builder
|
|
|
|
|
|
def test_runtime_rejects_insufficient_balanced_range_before_loading_panel(
|
|
tmp_path,
|
|
) -> None:
|
|
first = date(2023, 10, 13)
|
|
dates = [first + timedelta(days=offset) for offset in range(690)]
|
|
for value in dates:
|
|
partition = tmp_path / "kline_daily_enriched" / f"date={value.isoformat()}"
|
|
partition.mkdir(parents=True)
|
|
(partition / "part.parquet").touch()
|
|
|
|
payload = {
|
|
"run_id": "insufficient-balanced",
|
|
"request": {
|
|
"factor_names": ["turnover_rate"],
|
|
"strategy_ids": [],
|
|
"asset_type": "stock",
|
|
"budget_profile": "balanced",
|
|
"start": dates[0].isoformat(),
|
|
"end": dates[-1].isoformat(),
|
|
},
|
|
}
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=(
|
|
r"balanced mining requires at least 786 enriched trading bars for "
|
|
r"3 outer folds; effective range .* has 690"
|
|
),
|
|
):
|
|
_decode_runtime_request(payload, tmp_path, SimpleNamespace())
|
|
|
|
|
|
def test_single_forward_label_uses_global_trading_axis_without_jump() -> None:
|
|
first = date(2024, 1, 2)
|
|
missing = first + timedelta(days=1)
|
|
resumed = first + timedelta(days=2)
|
|
panel = pl.DataFrame({
|
|
"symbol": ["000001.SZ", "000001.SZ"],
|
|
"date": [first, resumed],
|
|
"close": [10.0, 12.0],
|
|
"turnover_rate": [1.0, 2.0],
|
|
"unused_factor": [9.0, 10.0],
|
|
})
|
|
|
|
result = attach_single_forward_return(
|
|
panel,
|
|
start=first,
|
|
end=resumed,
|
|
horizon=1,
|
|
trading_dates=[first, missing, resumed],
|
|
factor_names=["turnover_rate"],
|
|
)
|
|
|
|
first_row = result.filter(pl.col("date") == first).row(0, named=True)
|
|
assert first_row["_target_date"] == missing
|
|
assert first_row["_next_return"] is None
|
|
assert "_forward_return_1d" not in result.columns
|
|
assert "close" not in result.columns
|
|
assert "unused_factor" not in result.columns
|
|
assert result.columns == [
|
|
"symbol",
|
|
"date",
|
|
"turnover_rate",
|
|
"_next_return",
|
|
"_target_date",
|
|
]
|
|
assert result.schema["_next_return"] == pl.Float32
|
|
assert result.schema["turnover_rate"] == pl.Float32
|
|
|
|
fast = attach_single_forward_return(
|
|
panel.sort(["date", "symbol"]),
|
|
start=first,
|
|
end=resumed,
|
|
horizon=1,
|
|
trading_dates=[first, missing, resumed],
|
|
factor_names=["turnover_rate"],
|
|
assume_unique_symbol_date=True,
|
|
)
|
|
assert fast.equals(result)
|
|
|
|
|
|
def test_compact_factor_panel_matches_full_symbol_independent_calculation(
|
|
monkeypatch,
|
|
) -> None:
|
|
first = date(2024, 1, 2)
|
|
rows = []
|
|
for symbol, offset in (("a", 0.0), ("b", 2.0), ("c", 4.0)):
|
|
for day in range(70):
|
|
close = 10.0 + offset + day * 0.1
|
|
rows.append({
|
|
"symbol": symbol,
|
|
"date": first + timedelta(days=day),
|
|
"open": close - 0.1,
|
|
"high": close + 0.2,
|
|
"low": close - 0.2,
|
|
"close": close,
|
|
"volume": 1000.0 + day,
|
|
"amount": close * (1000.0 + day),
|
|
"turnover_rate": 1.0 + day / 100.0,
|
|
})
|
|
raw = pl.DataFrame(rows).sort(["symbol", "date"])
|
|
|
|
class Engine:
|
|
def load_panel(self, *_args, **_kwargs):
|
|
return raw
|
|
|
|
engine = Engine()
|
|
from app.backtest.factor import FactorBacktestService
|
|
|
|
factor_service = FactorBacktestService(engine)
|
|
config = SimpleNamespace(
|
|
symbols=None,
|
|
start=first,
|
|
end=first + timedelta(days=69),
|
|
asset_type="stock",
|
|
)
|
|
names = ("momentum_20d", "rsi_14", "ma20_bias")
|
|
full = factor_service._compute_missing_factors(
|
|
raw,
|
|
set(names),
|
|
assume_sorted=True,
|
|
).select(["symbol", "date", "close", *names]).with_columns([
|
|
pl.col(name).cast(pl.Float32) for name in names
|
|
]).sort(["date", "symbol"])
|
|
|
|
monkeypatch.setattr("app.backtest.mining_runtime._SYMBOL_BATCH_SIZE", 1)
|
|
compact = _load_compact_factor_panel(
|
|
factor_service,
|
|
config,
|
|
names,
|
|
expected_generation="generation",
|
|
cancel_check=None,
|
|
)
|
|
|
|
assert compact.equals(full)
|
|
|
|
|
|
def test_compact_factor_panel_rejects_noncanonical_symbol_date_keys() -> None:
|
|
first = date(2024, 1, 2)
|
|
canonical = pl.DataFrame({
|
|
"symbol": ["a", "a", "b"],
|
|
"date": [first, first + timedelta(days=1), first],
|
|
"open": [1.0, 1.0, 1.0],
|
|
"high": [1.0, 1.0, 1.0],
|
|
"low": [1.0, 1.0, 1.0],
|
|
"close": [1.0, 1.0, 1.0],
|
|
"volume": [1.0, 1.0, 1.0],
|
|
"amount": [1.0, 1.0, 1.0],
|
|
"turnover_rate": [1.0, 1.0, 1.0],
|
|
})
|
|
config = SimpleNamespace(
|
|
symbols=None,
|
|
start=first,
|
|
end=first + timedelta(days=1),
|
|
asset_type="stock",
|
|
)
|
|
|
|
class Engine:
|
|
def __init__(self, panel):
|
|
self.panel = panel
|
|
|
|
def load_panel(self, *_args, **_kwargs):
|
|
return self.panel
|
|
|
|
from app.backtest.factor import FactorBacktestService
|
|
|
|
for invalid in (
|
|
canonical.with_columns(pl.Series(
|
|
"date",
|
|
[first + timedelta(days=1), first, first],
|
|
)),
|
|
pl.concat([canonical.slice(0, 1), canonical]),
|
|
):
|
|
with pytest.raises(ValueError, match="unique symbol/date"):
|
|
_load_compact_factor_panel(
|
|
FactorBacktestService(Engine(invalid)),
|
|
config,
|
|
("turnover_rate",),
|
|
expected_generation="generation",
|
|
cancel_check=None,
|
|
)
|
|
|
|
|
|
def test_artifact_finalists_are_truncated_by_oos_sharpe_before_signature() -> None:
|
|
low = MiningCandidate(candidate_id="a-low", kind="existing_strategy", strategy_id="low")
|
|
high = MiningCandidate(candidate_id="z-high", kind="existing_strategy", strategy_id="high")
|
|
rows = [
|
|
{"candidate_signature": "a-low", "sharpe": 0.2, "skipped": False},
|
|
{"candidate_signature": "z-high", "sharpe": 1.4, "skipped": False},
|
|
]
|
|
|
|
assert _rank_artifact_candidates([low, high], rows, limit=1) == [high]
|
|
|
|
|
|
def test_prepare_base_market_forwards_cancel_event(monkeypatch, tmp_path) -> None:
|
|
cancel_event = object()
|
|
captured = {}
|
|
plan = SimpleNamespace(
|
|
base_columns=frozenset(),
|
|
intermediate_columns=frozenset(),
|
|
indicator_columns=frozenset(),
|
|
signal_columns=frozenset(),
|
|
matrix_columns=frozenset(),
|
|
instrument_columns=frozenset(),
|
|
warmup_bars=1,
|
|
full_feature_fallback=False,
|
|
execution_backend="matrix_native",
|
|
fundamental_columns=frozenset(),
|
|
)
|
|
research = SimpleNamespace(entry_signals=[], exit_signals=[])
|
|
strategy_engine = SimpleNamespace(get=lambda _strategy_id: research)
|
|
service = SimpleNamespace(
|
|
_effective_basic_filter=lambda *_args: {},
|
|
engine=SimpleNamespace(),
|
|
)
|
|
request = SimpleNamespace(
|
|
factor_names=("turnover_rate",),
|
|
strategy_ids=(),
|
|
asset_type="stock",
|
|
forward_horizon=1,
|
|
start=date(2024, 1, 2),
|
|
end=date(2024, 1, 3),
|
|
symbols=None,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"app.backtest.mining_runtime.StrategyDependencyResolver.resolve",
|
|
lambda *_args, **_kwargs: plan,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.backtest.mining_runtime.build_matrix_cache_profile",
|
|
lambda *_args, **_kwargs: SimpleNamespace(),
|
|
)
|
|
|
|
def load_matrix(*_args, **kwargs):
|
|
captured.update(kwargs)
|
|
return "market"
|
|
|
|
service.engine.load_market_data_matrix_for_backtest = load_matrix
|
|
|
|
result = _prepare_base_market(
|
|
service,
|
|
strategy_engine,
|
|
tmp_path,
|
|
request,
|
|
expected_generation="generation",
|
|
cancel_check=cancel_event,
|
|
)
|
|
|
|
assert result == "market"
|
|
assert captured["cancel_event"] is cancel_event
|
|
|
|
|
|
def test_training_metric_provider_uses_only_supplied_fold() -> None:
|
|
start = date(2024, 1, 2)
|
|
rows = []
|
|
for day_offset in range(3):
|
|
for asset_id in range(4):
|
|
rows.append({
|
|
"symbol": f"{asset_id:06d}.SZ",
|
|
"date": start + timedelta(days=day_offset),
|
|
"factor": float(asset_id),
|
|
"_next_return": (
|
|
float(asset_id) if day_offset < 2 else float(-asset_id)
|
|
),
|
|
})
|
|
panel = pl.DataFrame(rows)
|
|
train = panel.filter(pl.col("date") < start + timedelta(days=2))
|
|
|
|
provider = TrainingMetricProvider("_next_return")
|
|
metric = provider(train, ["factor"])[0]
|
|
|
|
assert metric.rank_ic == pytest.approx(1.0)
|
|
assert metric.coverage == pytest.approx(1.0)
|
|
assert provider.calls[0]["end"] == (start + timedelta(days=1)).isoformat()
|
|
assert provider.calls[0]["rows"] == 8
|
|
|
|
|
|
def test_regime_date_count_uses_t_minus_one_market_labels(tmp_path) -> None:
|
|
labels = [date(2024, 1, 2) + timedelta(days=offset) for offset in range(4)]
|
|
panel = pl.DataFrame({"date": labels})
|
|
regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({
|
|
"date": labels[:3],
|
|
"state": ["weak", "strong", "lean_strong"],
|
|
"score": [20, 80, 70],
|
|
}))
|
|
fold = SimpleNamespace(
|
|
test_start=labels[1].isoformat(),
|
|
test_end=labels[3].isoformat(),
|
|
)
|
|
|
|
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)
|