mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 22:34:18 +08:00
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动, EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合, 可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存 - 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档), 周度调度默认关闭且永不自动发布 - 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益, 信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错) - 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复 - 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
155 lines
5.5 KiB
Python
155 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.api import data as data_api
|
|
from app.backtest.engine import PanelCache
|
|
from app.enriched_generation import (
|
|
EnrichedGenerationUnavailableError,
|
|
get_enriched_generation,
|
|
)
|
|
|
|
|
|
class _RepoStub:
|
|
def __init__(self, data_dir: Path) -> None:
|
|
self.store = SimpleNamespace(data_dir=data_dir)
|
|
self.calls: list[str] = []
|
|
|
|
def clear_cache(self) -> None:
|
|
self.calls.append("clear_cache")
|
|
|
|
def refresh_cache(self) -> None:
|
|
self.calls.append("refresh_cache")
|
|
|
|
def rebuild_views(self) -> None:
|
|
self.calls.append("rebuild_views")
|
|
|
|
|
|
def _request(repo: _RepoStub) -> SimpleNamespace:
|
|
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo)))
|
|
|
|
|
|
def _write_parquet_placeholder(path: Path) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(b"parquet-placeholder")
|
|
|
|
|
|
def _stub_clear_side_effects(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from app.api import overview
|
|
from app.services import alert_store
|
|
from app.services.pipeline_jobs import job_store
|
|
from app.services.screener import ScreenerService
|
|
|
|
monkeypatch.setattr(job_store, "clear", lambda: None)
|
|
monkeypatch.setattr(alert_store, "clear", lambda _data_dir: None)
|
|
monkeypatch.setattr(ScreenerService, "clear_history_cache", lambda: None)
|
|
monkeypatch.setattr(overview, "invalidate_overview_cache", lambda: None)
|
|
monkeypatch.setattr(data_api, "invalidate_data_cache", lambda _table=None: None)
|
|
|
|
|
|
def test_clear_data_bumps_enriched_generations_and_invalidates_panel_cache(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_stub_clear_side_effects(monkeypatch)
|
|
repo = _RepoStub(tmp_path)
|
|
stock_file = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
|
|
etf_file = tmp_path / "kline_etf_enriched" / "date=2026-08-14" / "part.parquet"
|
|
_write_parquet_placeholder(stock_file)
|
|
_write_parquet_placeholder(etf_file)
|
|
stock_before = get_enriched_generation(tmp_path, "stock")
|
|
etf_before = get_enriched_generation(tmp_path, "etf")
|
|
|
|
cache = PanelCache()
|
|
cache_args = (["000001.SZ"], date(2026, 8, 14), date(2026, 8, 14), None)
|
|
computes: list[int] = []
|
|
|
|
def compute(*_args) -> pl.DataFrame:
|
|
computes.append(len(computes) + 1)
|
|
return pl.DataFrame({"value": [computes[-1]]})
|
|
|
|
cache.get_or_compute(*cache_args, compute, "stock", stock_before)
|
|
result = data_api.clear_data(_request(repo))
|
|
stock_after = get_enriched_generation(tmp_path, "stock")
|
|
etf_after = get_enriched_generation(tmp_path, "etf")
|
|
cached_after = cache.get_or_compute(*cache_args, compute, "stock", stock_after)
|
|
|
|
assert result == {"deleted_files": 2}
|
|
assert not stock_file.exists()
|
|
assert not etf_file.exists()
|
|
assert stock_after != stock_before
|
|
assert etf_after != etf_before
|
|
assert cached_after["value"].item() == 2
|
|
assert cache.stats()["compute_count"] == 2
|
|
assert repo.calls == ["clear_cache", "refresh_cache", "rebuild_views"]
|
|
|
|
|
|
def test_clear_data_restores_ready_generation_when_first_delete_fails(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repo = _RepoStub(tmp_path)
|
|
target = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
|
|
_write_parquet_placeholder(target)
|
|
generation_before = get_enriched_generation(tmp_path, "stock")
|
|
original_unlink = Path.unlink
|
|
|
|
def fail_target(path: Path, *args, **kwargs) -> None:
|
|
if path == target:
|
|
raise PermissionError("injected delete failure")
|
|
original_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", fail_target)
|
|
|
|
with pytest.raises(PermissionError, match="injected delete failure"):
|
|
data_api.clear_data(_request(repo))
|
|
|
|
assert target.is_file()
|
|
assert get_enriched_generation(tmp_path, "stock") == generation_before
|
|
marker = json.loads(
|
|
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
|
)
|
|
assert marker["state"] == "ready"
|
|
|
|
|
|
def test_clear_data_keeps_generation_publishing_after_partial_delete(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repo = _RepoStub(tmp_path)
|
|
enriched_dir = tmp_path / "kline_daily_enriched"
|
|
first = enriched_dir / "date=2026-08-13" / "part.parquet"
|
|
second = enriched_dir / "date=2026-08-14" / "part.parquet"
|
|
_write_parquet_placeholder(first)
|
|
_write_parquet_placeholder(second)
|
|
get_enriched_generation(tmp_path, "stock")
|
|
original_unlink = Path.unlink
|
|
parquet_unlinks = 0
|
|
|
|
def fail_second_parquet(path: Path, *args, **kwargs) -> None:
|
|
nonlocal parquet_unlinks
|
|
if path.suffix == ".parquet" and enriched_dir in path.parents:
|
|
parquet_unlinks += 1
|
|
if parquet_unlinks == 2:
|
|
raise PermissionError("injected partial delete failure")
|
|
original_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", fail_second_parquet)
|
|
|
|
with pytest.raises(PermissionError, match="injected partial delete failure"):
|
|
data_api.clear_data(_request(repo))
|
|
|
|
assert sum(path.exists() for path in (first, second)) == 1
|
|
marker = json.loads(
|
|
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
|
)
|
|
assert marker["state"] == "publishing"
|
|
with pytest.raises(EnrichedGenerationUnavailableError, match="being published"):
|
|
get_enriched_generation(tmp_path, "stock")
|