Files
tick-stock-panel/backend/tests/backtest/test_worker_process.py
T
shy3130 918c7ade7e refactor(core): 策略与信号全面去内置化, 转为用户自定义定义
- 策略: 引擎只加载 data/strategies/{custom,ai,composite} 与研究模板目录,
  原 25 个内置策略一次性迁移为用户自定义文件(ID 不变, 引用零迁移);
  研究模板移至 app/strategy/research, 打包 spec 同步; 新装环境策略库为空
- 信号: 管线不再硬编码任何信号表达式; 涨跌停判定价成为运行时列
  limit_up_price/limit_down_price(生效价∓0.005 容差), 20 个信号转为
  data/user_data/custom_signals 定义(显式 column 沿用 signal_* 原列名);
  依赖解析/注入顺序/当日路径 prev-join 全部定义驱动; 语义统一为历史路径
  口径(顺带修正当日路径 BOLL 比较符与翘板/炸板空值两处既有偏差)
- 前端: 信号库单列表(全部可编辑), SignalPicker/监控选项/信号标签全走
  定义接口; 回测页新增免责声明(模拟性质/数据源准确性不作保证/不构成
  投资建议)
- 测试: 25 个策略夹具与 20 个信号种子入库 tests/fixtures, 受影响测试
  全部自包含化(不依赖运行目录用户数据); 77 万行 MA 死叉新旧表达式对拍
  逐行一致; 回测实跑 45 笔卖点信号出场验证矩阵链路
- 同批包含工作区既有进度: 策略结果 ext 列同日投影修复(含测试)、数据源插件、fuyao/auth/api_gateway/data_integrity 调整、README 信号库
  描述与 strategy.md 导入路径补充

验证: 后端 2592 passed(空数据目录 CI 模拟 2588 passed, 4 例为模拟环境
伪影复验排除); 前端 vitest 125 passed + tsc 0 错 + vite build 通过
2026-10-10 21:10:44 +08:00

669 lines
22 KiB
Python

from __future__ import annotations
import queue
import threading
from datetime import date, timedelta
from pathlib import Path
from types import SimpleNamespace
import polars as pl
import pytest
from app.backtest import worker as worker_module
from app.backtest.mining import benchmark_candidate
from app.backtest.optimizer import OptimizeConfig
from app.backtest.strategy import StrategyBacktestConfig
from app.backtest.walkforward import WalkForwardConfig
from app.backtest.worker import BacktestWorkerError, make_worker_task, run_worker_task
from app.enriched_generation import bump_enriched_generation, get_enriched_generation
from app.services.mining_jobs import MiningRunStore
def _write_worker_strategy(data_dir) -> None:
strategy_dir = data_dir / "strategies" / "custom"
strategy_dir.mkdir(parents=True)
(strategy_dir / "worker_always_entry.py").write_text(
"""import numpy as np
from app.backtest.matrix import make_signal_matrix
META = {
"id": "worker_always_entry",
"name": "worker",
"asset_types": ["stock"],
"timeframes": ["1d"],
"params": [
{"id": "gate", "type": "int", "default": 1, "min": 1, "max": 2, "step": 1},
],
"scoring": {},
"required_features": ["open", "high", "low", "close", "volume"],
}
EXECUTION_BACKEND = "matrix_native"
ENTRY_SIGNALS = []
EXIT_SIGNALS = []
STOP_LOSS = None
MAX_HOLD_DAYS = 1
class AlwaysEntry:
def required_fields(self):
return frozenset({"open", "high", "low", "close", "volume"})
def required_warmup_bars(self, params):
return 1
def compute_signals(self, market, params):
return make_signal_matrix(
market.shape,
entry=np.ones(market.shape, dtype=np.uint8),
)
MATRIX_STRATEGY = AlwaysEntry()
""",
encoding="utf-8",
)
def _write_market_data(data_dir, start: date, days: int = 3) -> None:
rows = []
for offset in range(days):
close = 10.0 + offset
current = start + timedelta(days=offset)
rows.append({
"symbol": "600000.SH",
"date": current,
"open": close,
"high": close,
"low": close,
"close": close,
"volume": 1000.0,
"amount": close * 100000.0,
"raw_close": close,
"raw_high": close,
"raw_low": close,
"turnover_rate": 1.0,
"consecutive_limit_ups": 0,
"consecutive_limit_downs": 0,
})
partition = data_dir / "kline_daily_enriched" / f"date={current.isoformat()}"
partition.mkdir(parents=True)
pl.DataFrame([rows[-1]]).write_parquet(partition / "part.parquet")
instruments_dir = data_dir / "instruments"
instruments_dir.mkdir(parents=True)
pl.DataFrame({
"symbol": ["600000.SH"],
"name": ["浦发银行"],
"total_shares": [1_000_000_000.0],
"float_shares": [1_000_000_000.0],
}).write_parquet(instruments_dir / "part.parquet")
def _write_mining_market_data(
data_dir,
start: date,
*,
days: int = 219,
assets: int = 4,
) -> None:
symbols = [f"60000{asset}.SH" for asset in range(assets)]
for offset in range(days):
current = start + timedelta(days=offset)
rows = []
for asset_id, symbol in enumerate(symbols):
close = 10.0 + asset_id + offset * (0.01 + asset_id * 0.002)
rows.append({
"symbol": symbol,
"date": current,
"open": close,
"high": close * 1.01,
"low": close * 0.99,
"close": close,
"volume": 1000.0 + asset_id * 100.0,
"amount": close * (100000.0 + asset_id * 1000.0),
"raw_close": close,
"raw_high": close * 1.01,
"raw_low": close * 0.99,
"turnover_rate": 1.0 + asset_id * 0.5 + offset * 0.001,
"consecutive_limit_ups": 0,
"consecutive_limit_downs": 0,
})
partition = data_dir / "kline_daily_enriched" / f"date={current.isoformat()}"
partition.mkdir(parents=True)
pl.DataFrame(rows).write_parquet(partition / "part.parquet")
instruments_dir = data_dir / "instruments"
instruments_dir.mkdir(parents=True)
pl.DataFrame({
"symbol": symbols,
"name": [f"测试{asset}" for asset in range(assets)],
"total_shares": [1_000_000_000.0] * assets,
"float_shares": [1_000_000_000.0] * assets,
}).write_parquet(instruments_dir / "part.parquet")
def test_spawn_worker_returns_compact_result_and_memory_metrics(tmp_path):
start = date(2024, 1, 1)
data_dir = tmp_path / "data"
_write_worker_strategy(data_dir)
_write_market_data(data_dir, start)
config = StrategyBacktestConfig(
strategy_id="worker_always_entry",
symbols=["600000.SH"],
start=start,
end=start + timedelta(days=2),
overrides={"basic_filter": {"enabled": False}},
matching="close_t",
fees_pct=0,
slippage_bps=0,
max_positions=1,
)
result = run_worker_task(make_worker_task("backtest", data_dir, config))
assert result["error"] is None
assert result["stats"]["execution_backend"] == "matrix_native"
worker = result["stats"]["worker"]
assert worker["peak_rss_bytes"] > 0
assert worker["serialized_result_bytes"] > 0
assert worker["worker_exitcode"] == 0
assert worker["parent_rss_after_worker_exit_bytes"] > 0
def test_spawn_optimizer_reuses_one_matrix_and_exits(tmp_path):
start = date(2024, 1, 1)
data_dir = tmp_path / "data"
_write_worker_strategy(data_dir)
_write_market_data(data_dir, start)
config = OptimizeConfig(
strategy_id="worker_always_entry",
symbols=["600000.SH"],
start=start,
end=start + timedelta(days=2),
param_grid={"gate": [1, 2]},
objective="total_return",
max_workers=4,
overrides={"basic_filter": {"enabled": False}},
backtest_kwargs={
"matching": "close_t",
"fees_pct": 0,
"slippage_bps": 0,
"max_positions": 1,
},
)
result = run_worker_task(make_worker_task("optimize", data_dir, config))
assert result["n_completed"] == 2
assert result["effective_workers"] == 1
assert result["shared_market_data"] is True
assert result["shared_market_data_bytes"] > 0
assert result["best_backtest"]["equity_curve"]
assert result["best_backtest"]["trades"]
assert "mc_maxdd_p50" in result["best_backtest"]["stats"]
assert result["matrix_compute_cache"]["released"] is True
assert result["performance"]["trial_peak_rss_bytes"] > 0
assert result["performance"]["best_backtest_peak_rss_bytes"] > 0
assert result["performance"]["trials_per_second"] > 0
assert result["worker"]["worker_exitcode"] == 0
def test_spawn_walkforward_reuses_shared_matrix_across_folds(tmp_path):
start = date(2024, 1, 1)
data_dir = tmp_path / "data"
_write_worker_strategy(data_dir)
_write_market_data(data_dir, start, days=8)
config = WalkForwardConfig(
strategy_id="worker_always_entry",
symbols=["600000.SH"],
start=start,
end=start + timedelta(days=7),
param_grid={"gate": [1, 2]},
objective="total_return",
train_days=2,
test_days=1,
step_days=2,
overrides={"basic_filter": {"enabled": False}},
backtest_kwargs={
"matching": "close_t",
"fees_pct": 0,
"slippage_bps": 0,
"max_positions": 1,
},
)
result = run_worker_task(make_worker_task("walkforward", data_dir, config))
assert result["n_folds"] == 2
assert result["shared_market_data"] is True
assert result["shared_market_data_bytes"] > 0
assert all(fold["oos_stats"]["shared_market_data"] for fold in result["folds"])
assert result["worker"]["worker_exitcode"] == 0
def test_spawn_mining_writes_four_artifacts_and_returns_compact_summary(tmp_path):
start = date(2023, 1, 2)
data_dir = tmp_path / "data"
_write_mining_market_data(data_dir, start)
store = MiningRunStore(data_dir)
manifest = store.create(
{
"factor_names": ["turnover_rate"],
"strategy_ids": [],
"symbols": None,
"asset_type": "stock",
"start": (start - timedelta(days=7)).isoformat(),
"end": (start + timedelta(days=225)).isoformat(),
"budget_profile": "exploratory",
"forward_horizon": 1,
"commission_pct": 0.0,
"stamp_tax_pct": 0.0,
"slippage_bps": 0.0,
"correlation_threshold": 0.75,
"max_combination_factors": 1,
"beam_width": 2,
"max_finalists": 2,
"require_regime": False,
},
{"generation": get_enriched_generation(data_dir, "stock")},
run_id="spawn_mining",
)
payload = {
"run_id": manifest["run_id"],
"request": manifest["request"],
"data_fingerprint": manifest["data_fingerprint"],
"source": "manual",
}
result = run_worker_task(make_worker_task("mining", data_dir, payload))
assert result["status"] in {"succeeded", "succeeded_with_budget_exhausted"}
assert result["factor_count"] == 1
assert result["data_as_of"] == (start + timedelta(days=218)).isoformat()
assert result["panel_scans"] == 1
assert result["matrix_bytes"] > 0
assert result["worker"]["worker_exitcode"] == 0
assert result["worker"]["serialized_result_bytes"] < 100_000
registered = store.get("spawn_mining")["artifacts"] # type: ignore[index]
assert set(registered) == {"factors", "correlation", "candidates", "folds"}
for name in registered:
artifact = store.artifact_path("spawn_mining", name)
assert artifact.is_file()
frame = pl.read_parquet(artifact)
assert frame.columns
if name == "folds":
assert "n_dates" in frame.columns
assert frame.filter(pl.col("regime_state") == "overall")["n_dates"].min() > 0
def test_spawn_mining_benchmarks_strategy_on_every_outer_fold(tmp_path):
start = date(2023, 1, 2)
data_dir = tmp_path / "data"
_write_mining_market_data(data_dir, start)
# 策略是用户数据: worker 只加载 data 目录下的策略, 把夹具拷进临时 custom 目录
import shutil
fixtures = Path(__file__).resolve().parents[1] / "fixtures" / "strategies"
custom = data_dir / "strategies" / "custom"
custom.mkdir(parents=True, exist_ok=True)
shutil.copy(fixtures / "low_volatility_leader.py", custom / "low_volatility_leader.py")
store = MiningRunStore(data_dir)
manifest = store.create(
{
"factor_names": ["turnover_rate"],
"strategy_ids": ["low_volatility_leader"],
"symbols": None,
"asset_type": "stock",
"start": (start - timedelta(days=7)).isoformat(),
"end": (start + timedelta(days=225)).isoformat(),
"budget_profile": "exploratory",
"forward_horizon": 1,
"commission_pct": 0.0,
"stamp_tax_pct": 0.0,
"slippage_bps": 0.0,
"correlation_threshold": 0.75,
"max_combination_factors": 1,
"beam_width": 2,
"max_finalists": 2,
"require_regime": False,
},
{"generation": get_enriched_generation(data_dir, "stock")},
run_id="spawn_mining_benchmark",
)
payload = {
"run_id": manifest["run_id"],
"request": manifest["request"],
"data_fingerprint": manifest["data_fingerprint"],
"source": "manual",
}
result = run_worker_task(make_worker_task("mining", data_dir, payload))
assert result["status"] in {"succeeded", "succeeded_with_budget_exhausted"}
folds = pl.read_parquet(store.artifact_path("spawn_mining_benchmark", "folds"))
benchmark_signature = benchmark_candidate("low_volatility_leader").candidate_id
benchmark_rows = folds.filter(
(pl.col("evaluation_kind") == "benchmark")
& (pl.col("candidate_signature") == benchmark_signature)
& (pl.col("regime_state") == "overall")
)
outer_folds = result["valid_fold_count"] + result["skipped_fold_count"]
assert outer_folds >= 1
assert benchmark_rows.height == outer_folds
selected_rows = folds.filter(
(pl.col("evaluation_kind") == "selected")
& (pl.col("regime_state") == "overall")
)
assert selected_rows.height == outer_folds
candidates = pl.read_parquet(store.artifact_path("spawn_mining_benchmark", "candidates"))
assert benchmark_signature in candidates["signature"].to_list()
assert "existing_strategy" in candidates["kind"].to_list()
def test_spawn_mining_rejects_generation_change_after_queue(tmp_path):
start = date(2023, 1, 2)
data_dir = tmp_path / "data"
_write_mining_market_data(data_dir, start)
store = MiningRunStore(data_dir)
queued_generation = get_enriched_generation(data_dir, "stock")
manifest = store.create(
{
"factor_names": ["turnover_rate"],
"strategy_ids": [],
"symbols": None,
"asset_type": "stock",
"start": (start - timedelta(days=7)).isoformat(),
"end": (start + timedelta(days=225)).isoformat(),
"budget_profile": "exploratory",
"forward_horizon": 1,
"commission_pct": 0.0,
"stamp_tax_pct": 0.0,
"slippage_bps": 0.0,
"correlation_threshold": 0.75,
"max_combination_factors": 1,
"beam_width": 2,
"max_finalists": 2,
"require_regime": False,
},
{"generation": queued_generation},
run_id="stale_generation_mining",
)
bump_enriched_generation(data_dir, "stock")
payload = {
"run_id": manifest["run_id"],
"request": manifest["request"],
"data_fingerprint": manifest["data_fingerprint"],
"source": "manual",
}
with pytest.raises(
BacktestWorkerError,
match="changed after the run was queued",
):
run_worker_task(make_worker_task("mining", data_dir, payload))
assert store.get("stale_generation_mining")["artifacts"] == {} # type: ignore[index]
def test_worker_terminates_child_after_cancel_grace(monkeypatch, tmp_path):
class FakeQueue:
def get(self, timeout):
raise queue.Empty
def close(self):
pass
def join_thread(self):
pass
class FakeEvent:
def set(self):
pass
class FakeProcess:
def __init__(self):
self.alive = True
self.exitcode = None
def start(self):
pass
def is_alive(self):
return self.alive
def join(self, timeout=None):
pass
def terminate(self):
self.alive = False
self.exitcode = -15
process = FakeProcess()
context = SimpleNamespace(
Queue=FakeQueue,
Event=FakeEvent,
Process=lambda **_kwargs: process,
)
clock = iter([0.0, 0.0, 0.0, 6.0])
monkeypatch.setattr(worker_module.mp, "get_context", lambda _method: context)
monkeypatch.setattr(worker_module.time, "monotonic", lambda: next(clock))
cancel_event = threading.Event()
cancel_event.set()
with pytest.raises(BacktestWorkerError, match="after cancellation"):
run_worker_task(
{"kind": "mining", "data_dir": str(tmp_path), "config": {}},
cancel_event=cancel_event,
)
assert process.exitcode == -15
def test_worker_accepts_delivered_result_when_child_exit_is_slow(monkeypatch, tmp_path):
"""终态消息已送达但子进程退出收尾超时: 应强杀后采纳结果, 而非丢弃报错。"""
class FakeQueue:
def __init__(self):
self._messages = [{"type": "result", "payload": {"status": "ok"}}]
def get(self, timeout):
if self._messages:
return self._messages.pop(0)
raise queue.Empty
def close(self):
pass
def join_thread(self):
pass
class FakeEvent:
def set(self):
pass
class FakeProcess:
def __init__(self):
self.alive = True
self.exitcode = None
def start(self):
pass
def is_alive(self):
return self.alive
def join(self, timeout=None):
pass
def terminate(self):
self.alive = False
self.exitcode = -15
process = FakeProcess()
context = SimpleNamespace(
Queue=FakeQueue,
Event=FakeEvent,
Process=lambda **_kwargs: process,
)
monkeypatch.setattr(worker_module.mp, "get_context", lambda _method: context)
result = run_worker_task({"kind": "mining", "data_dir": str(tmp_path), "config": {}})
assert result["status"] == "ok"
assert result["worker"]["worker_exit_forcibly"] is True
assert result["worker"]["worker_exitcode"] == -15
assert process.exitcode == -15
def test_spawn_walkforward_skips_folds_before_available_matrix_data(tmp_path):
configured_start = date(2024, 1, 1)
market_start = configured_start + timedelta(days=4)
data_dir = tmp_path / "data"
_write_worker_strategy(data_dir)
_write_market_data(data_dir, market_start, days=8)
config = WalkForwardConfig(
strategy_id="worker_always_entry",
symbols=["600000.SH"],
start=configured_start,
end=configured_start + timedelta(days=11),
param_grid={"gate": [1, 2]},
objective="total_return",
train_days=2,
test_days=1,
step_days=2,
overrides={"basic_filter": {"enabled": False}},
backtest_kwargs={
"matching": "close_t",
"fees_pct": 0,
"slippage_bps": 0,
"max_positions": 1,
},
)
result = run_worker_task(make_worker_task("walkforward", data_dir, config))
assert result["n_planned_folds"] == 4
assert result["n_skipped"] == 1
assert result["skipped"][0]["reason"] == "训练区间无可用行情数据"
assert result["n_folds"] == 3
assert result["worker"]["worker_exitcode"] == 0
def _mining_runtime_services(data_dir):
"""按 worker._worker_entry 的方式在进程内构造挖掘运行时依赖 (便于 monkeypatch)。"""
from app.backtest.engine import BacktestEngine
from app.backtest.strategy import StrategyBacktestService
from app.strategy import config as strategy_config
from app.strategy.engine import StrategyEngine
from app.tickflow.repository import DataStore, KlineRepository
store = DataStore(data_dir)
repo = KlineRepository(store)
strategy_engine = StrategyEngine(
strategy_dirs=worker_module._strategy_dirs(data_dir),
override_loader=lambda sid: strategy_config.load_override(data_dir, sid),
)
service = StrategyBacktestService(BacktestEngine(repo), strategy_engine)
return service, strategy_engine
def _queue_mining_run(data_dir, start: date, run_id: str) -> dict:
store = MiningRunStore(data_dir)
manifest = store.create(
{
"factor_names": ["turnover_rate"],
"strategy_ids": [],
"symbols": None,
"asset_type": "stock",
"start": (start - timedelta(days=7)).isoformat(),
"end": (start + timedelta(days=225)).isoformat(),
"budget_profile": "exploratory",
"forward_horizon": 1,
"commission_pct": 0.0,
"stamp_tax_pct": 0.0,
"slippage_bps": 0.0,
"correlation_threshold": 0.75,
"max_combination_factors": 1,
"beam_width": 2,
"max_finalists": 2,
"require_regime": False,
},
{"generation": get_enriched_generation(data_dir, "stock")},
run_id=run_id,
)
return {
"run_id": manifest["run_id"],
"request": manifest["request"],
"data_fingerprint": manifest["data_fingerprint"],
"source": "manual",
}
def _patch_generation_drift(monkeypatch, data_dir, *, always: bool) -> dict:
"""让世代校验第一次(或每次)调用前先 bump 再抛错, 模拟读取期间并发发布完成。"""
from app.backtest.engine import BacktestEngine
from app.enriched_generation import EnrichedGenerationUnavailableError
original = BacktestEngine.assert_data_generation
state = {"drifts": 0}
def drift(engine_self, asset_type, expected):
if expected is not None and (always or state["drifts"] == 0):
state["drifts"] += 1
bump_enriched_generation(data_dir, asset_type)
raise EnrichedGenerationUnavailableError(
"simulated concurrent publication"
)
return original(engine_self, asset_type, expected)
monkeypatch.setattr(BacktestEngine, "assert_data_generation", drift)
return state
def test_mining_rereads_snapshot_when_generation_commits_mid_read(
monkeypatch, tmp_path
):
start = date(2023, 1, 2)
data_dir = tmp_path / "data"
_write_mining_market_data(data_dir, start)
payload = _queue_mining_run(data_dir, start, "midread_drift_mining")
service, strategy_engine = _mining_runtime_services(data_dir)
state = _patch_generation_drift(monkeypatch, data_dir, always=False)
from app.backtest.mining_runtime import run_mining_runtime
events: list[dict] = []
result = run_mining_runtime(
payload,
data_dir=data_dir,
service=service,
strategy_engine=strategy_engine,
progress_cb=events.append,
cancel_check=None,
)
assert result["status"] in {"succeeded", "succeeded_with_budget_exhausted"}
assert state["drifts"] == 1
assert any(
event.get("label") == "数据已更新, 重新读取快照" for event in events
)
def test_mining_fails_with_guidance_when_generation_keeps_drifting(
monkeypatch, tmp_path
):
start = date(2023, 1, 2)
data_dir = tmp_path / "data"
_write_mining_market_data(data_dir, start)
payload = _queue_mining_run(data_dir, start, "endless_drift_mining")
service, strategy_engine = _mining_runtime_services(data_dir)
_patch_generation_drift(monkeypatch, data_dir, always=True)
from app.backtest.mining_runtime import run_mining_runtime
with pytest.raises(ValueError, match="稳定快照"):
run_mining_runtime(
payload,
data_dir=data_dir,
service=service,
strategy_engine=strategy_engine,
progress_cb=None,
cancel_check=None,
)