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