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

428 lines
14 KiB
Python

from __future__ import annotations
from datetime import date, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import polars as pl
import pytest
from app.jobs import daily_pipeline
from app.services import mining_schedule, preferences
from app.services.mining_jobs import MiningRunStore
class FakeRepo:
def __init__(self, data_dir: Path, *, latest: date = date(2026, 8, 14)) -> None:
self.store = SimpleNamespace(data_dir=data_dir)
self.latest = latest
self.generation = "generation-1"
def latest_enriched_date(self, asset_type: str = "stock") -> date | None:
assert asset_type == "stock"
return self.latest
def get_matrix_data_generation(self, asset_type: str = "stock") -> str:
assert asset_type == "stock"
return self.generation
def get_instruments_asset(self, asset_type: str = "stock") -> pl.DataFrame:
assert asset_type == "stock"
return pl.DataFrame({
"symbol": ["000001.SZ"],
"name": ["示例"],
"total_shares": [1_000_000.0],
"float_shares": [800_000.0],
})
class FakeManager:
def __init__(self, data_dir: Path) -> None:
self.store = MiningRunStore(data_dir)
self.calls: list[dict] = []
def start(self, request, fingerprint, *, force: bool, source: str, run_id: str):
call = {
"request": request,
"fingerprint": fingerprint,
"force": force,
"source": source,
"run_id": run_id,
}
self.calls.append(call)
manifest = self.store.create(request, fingerprint, run_id=run_id)
return {"run_id": manifest["run_id"]}
@pytest.fixture
def scheduled_state(tmp_path: Path, monkeypatch):
repo = FakeRepo(tmp_path)
manager = FakeManager(tmp_path)
state = SimpleNamespace(repo=repo, mining_manager=manager, strategy_engine=None)
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 4,
"mining_budget_profile": "balanced",
},
)
_write_prerequisites(tmp_path, repo.latest, days=1200)
return state
def _friday(week_offset: int = 0) -> datetime:
return datetime(2026, 8, 14, 16, tzinfo=ZoneInfo("Asia/Shanghai")) + timedelta(
weeks=week_offset
)
def _write_prerequisites(data_dir: Path, latest: date, *, days: int) -> None:
enriched = data_dir / "kline_daily_enriched"
trading_dates: list[date] = []
for offset in range(days):
day = latest - timedelta(days=offset)
if day.weekday() >= 5:
continue
trading_dates.append(day)
partition = enriched / f"date={day.isoformat()}"
partition.mkdir(parents=True, exist_ok=True)
(partition / "part.parquet").write_bytes(b"enriched")
regime = data_dir / "regime_history" / "part.parquet"
regime.parent.mkdir(parents=True, exist_ok=True)
pl.DataFrame({
"date": sorted(trading_dates),
"state": ["range"] * len(trading_dates),
}).write_parquet(regime)
def test_beijing_date_and_iso_week_use_china_timezone():
utc = ZoneInfo("UTC")
instant = datetime(2026, 8, 13, 16, 30, tzinfo=utc)
assert mining_schedule.beijing_date(instant) == date(2026, 8, 14)
assert mining_schedule.iso_week(date(2027, 1, 1)) == (2026, 53)
def test_fingerprint_retries_generation_change_and_returns_stable_token(
tmp_path,
monkeypatch,
) -> None:
repo = FakeRepo(tmp_path)
state = SimpleNamespace(strategy_engine=None)
generations = iter([
"generation-1",
"generation-2",
"generation-2",
"generation-2",
])
monkeypatch.setattr(repo, "get_matrix_data_generation", lambda _asset: next(generations))
fingerprint = mining_schedule.build_data_fingerprint(
repo,
state,
{"asset_type": "stock", "strategy_ids": []},
)
assert fingerprint["generation"] == "generation-2"
def test_implementation_metadata_is_recursive_content_based_and_root_independent(
tmp_path,
) -> None:
roots = [tmp_path / "first" / "app", tmp_path / "second" / "app"]
for root in roots:
nested = root / "backtest"
nested.mkdir(parents=True)
(root / "main.py").write_text("VALUE = 1\n", encoding="utf-8")
(nested / "runtime.py").write_text("RESULT = 1\n", encoding="utf-8")
(nested / "ignored.txt").write_text("ignored\n", encoding="utf-8")
first = mining_schedule._implementation_metadata(roots[0])
second = mining_schedule._implementation_metadata(roots[1])
(roots[1] / "backtest" / "runtime.py").write_text("RESULT = 2\n", encoding="utf-8")
changed = mining_schedule._implementation_metadata(roots[1])
assert first == second
assert first["file_count"] == 2
assert str(tmp_path) not in str(first)
assert first["digest"] != changed["digest"]
def test_fingerprint_covers_result_implementation_digest(
tmp_path,
monkeypatch,
) -> None:
repo = FakeRepo(tmp_path)
state = SimpleNamespace(strategy_engine=None)
first = mining_schedule.build_data_fingerprint(
repo,
state,
{"asset_type": "stock", "strategy_ids": []},
)
monkeypatch.setattr(
mining_schedule,
"_implementation_metadata",
lambda _root: {"file_count": 1, "digest": "changed-runtime"},
)
second = mining_schedule.build_data_fingerprint(
repo,
state,
{"asset_type": "stock", "strategy_ids": []},
)
assert first["implementation"] != second["implementation"]
assert first["digest"] != second["digest"]
def test_selected_strategy_metadata_changes_with_same_size_source_edit(tmp_path) -> None:
source = tmp_path / "strategies" / "custom" / "demo.py"
source.parent.mkdir(parents=True)
source.write_text("VALUE = 1\n", encoding="utf-8")
strategy = SimpleNamespace(execution_backend="matrix_native", file_path=source)
state = SimpleNamespace(
strategy_engine=SimpleNamespace(get=lambda _strategy_id: strategy)
)
first = mining_schedule._selected_strategy_metadata(
state,
["demo"],
tmp_path,
)
source.write_text("VALUE = 2\n", encoding="utf-8")
second = mining_schedule._selected_strategy_metadata(
state,
["demo"],
tmp_path,
)
assert first[0]["source"]["size"] == second[0]["source"]["size"]
assert first[0]["source"]["sha256"] != second[0]["source"]["sha256"]
assert first[0]["source_tree"]["digest"] != second[0]["source_tree"]["digest"]
def test_fingerprint_rejects_continuously_changing_generation(
tmp_path,
monkeypatch,
) -> None:
repo = FakeRepo(tmp_path)
state = SimpleNamespace(strategy_engine=None)
generations = iter(["a", "b", "c", "d"])
monkeypatch.setattr(repo, "get_matrix_data_generation", lambda _asset: next(generations))
with pytest.raises(ValueError, match="changed"):
mining_schedule.build_data_fingerprint(
repo,
state,
{"asset_type": "stock", "strategy_ids": []},
)
def test_disabled_and_before_scheduled_weekday_do_not_enqueue(
scheduled_state,
monkeypatch,
):
state = scheduled_state
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": False,
"mining_schedule_weekday": 4,
"mining_budget_profile": "balanced",
},
)
assert mining_schedule.run_weekly_mining(state, now=_friday())["status"] == "disabled"
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 4,
"mining_budget_profile": "balanced",
},
)
thursday = _friday() - timedelta(days=1)
assert mining_schedule.run_weekly_mining(state, now=thursday)["status"] == "weekday_mismatch"
assert state.mining_manager.calls == []
def test_later_workday_catches_up_once_in_same_iso_week(scheduled_state, monkeypatch):
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 3,
"mining_budget_profile": "balanced",
},
)
first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
second = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
assert first["status"] == "enqueued"
assert second == {"status": "already_claimed", "run_id": first["run_id"]}
assert len(scheduled_state.mining_manager.calls) == 1
def test_same_week_and_fingerprint_enqueue_once(scheduled_state):
first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
second = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
assert first["status"] == "enqueued"
assert second == {"status": "already_claimed", "run_id": first["run_id"]}
assert len(scheduled_state.mining_manager.calls) == 1
call = scheduled_state.mining_manager.calls[0]
assert call["force"] is False
assert call["source"] == "scheduled"
assert call["run_id"] == first["run_id"]
assert call["run_id"] == call["fingerprint"]["source_claim"]
assert call["request"]["asset_type"] == "stock"
assert call["request"]["symbols"] is None
assert call["request"]["strategy_ids"] == []
assert call["request"]["require_regime"] is True
assert call["request"]["end"] == "2026-08-14"
assert len(call["request"]["factor_names"]) <= 48
def test_profile_change_cannot_bypass_same_week_claim(scheduled_state, monkeypatch):
first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
scheduled_state.mining_manager.store.transition_status(
first["run_id"], "failed", error="worker failed"
)
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 4,
"mining_budget_profile": "strict",
},
)
second = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
assert second == {"status": "already_claimed", "run_id": first["run_id"]}
assert len(scheduled_state.mining_manager.calls) == 1
def test_new_week_creates_new_claim_but_same_week_metadata_change_does_not(
scheduled_state,
):
manager = scheduled_state.mining_manager
first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
next_week = mining_schedule.run_weekly_mining(scheduled_state, now=_friday(1))
latest_file = (
scheduled_state.repo.store.data_dir
/ "kline_daily_enriched"
/ "date=2026-08-14"
/ "part.parquet"
)
latest_file.write_bytes(b"changed-enriched-metadata")
changed = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
assert first["run_id"] != next_week["run_id"]
assert changed == {"status": "already_claimed", "run_id": first["run_id"]}
assert len(manager.calls) == 2
def test_missing_regime_records_visible_skipped_prerequisite(scheduled_state):
regime = scheduled_state.repo.store.data_dir / "regime_history" / "part.parquet"
regime.unlink()
result = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
manifest = scheduled_state.mining_manager.store.get(result["run_id"])
assert result["status"] == "skipped_prerequisite"
assert manifest is not None
assert manifest["status"] == "skipped_prerequisite"
assert "regime" in manifest["error"]
assert scheduled_state.mining_manager.calls == []
def test_incomplete_regime_coverage_records_visible_skip(scheduled_state):
regime_path = (
scheduled_state.repo.store.data_dir
/ "regime_history"
/ "part.parquet"
)
history = pl.read_parquet(regime_path).sort("date")
history.filter(pl.col("date") != history["date"][-2]).write_parquet(regime_path)
result = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
manifest = scheduled_state.mining_manager.store.get(result["run_id"])
assert result["status"] == "skipped_prerequisite"
assert manifest is not None
assert "T-1" in manifest["error"]
assert scheduled_state.mining_manager.calls == []
def test_early_regime_gap_records_visible_skipped_prerequisite(scheduled_state):
data_dir = scheduled_state.repo.store.data_dir
regime_path = data_dir / "regime_history" / "part.parquet"
history = pl.read_parquet(regime_path).sort("date")
history.slice(1).write_parquet(regime_path)
result = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
manifest = scheduled_state.mining_manager.store.get(result["run_id"])
assert result["status"] == "skipped_prerequisite"
assert manifest is not None
assert "T-1" in manifest["error"]
assert scheduled_state.mining_manager.calls == []
def test_insufficient_data_records_visible_skipped_prerequisite(tmp_path, monkeypatch):
repo = FakeRepo(tmp_path)
manager = FakeManager(tmp_path)
state = SimpleNamespace(repo=repo, mining_manager=manager, strategy_engine=None)
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 4,
"mining_budget_profile": "strict",
},
)
_write_prerequisites(tmp_path, repo.latest, days=30)
result = mining_schedule.run_weekly_mining(state, now=_friday())
manifest = manager.store.get(result["run_id"])
assert result["status"] == "skipped_prerequisite"
assert manifest is not None
assert manifest["status"] == "skipped_prerequisite"
assert "insufficient" in manifest["error"]
assert manager.calls == []
def test_pipeline_failure_does_not_trigger_mining(monkeypatch):
mining_calls = []
monkeypatch.setattr(daily_pipeline, "_run_tracked", lambda *_args: False)
monkeypatch.setattr(
"app.services.mining_schedule.run_weekly_mining",
lambda state: mining_calls.append(state),
)
daily_pipeline._scheduled_pipeline_task(lambda: None)
assert mining_calls == []
def test_enqueue_failure_does_not_escape_successful_pipeline(monkeypatch):
monkeypatch.setattr(daily_pipeline, "_run_tracked", lambda *_args: True)
def fail_enqueue(_state):
raise RuntimeError("queue unavailable")
monkeypatch.setattr("app.services.mining_schedule.run_weekly_mining", fail_enqueue)
daily_pipeline._scheduled_pipeline_task(lambda: None)