mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动, EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合, 可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存 - 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档), 周度调度默认关闭且永不自动发布 - 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益, 信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错) - 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复 - 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
315 lines
9.7 KiB
Python
315 lines
9.7 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
|
|
from app.backtest.mining_runtime import (
|
|
TrainingMetricProvider,
|
|
_decode_runtime_request,
|
|
_load_compact_factor_panel,
|
|
_prepare_base_market,
|
|
_rank_artifact_candidates,
|
|
_regime_date_count,
|
|
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
|