Files
tick-stock-panel/backend/app/backtest/worker.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

345 lines
12 KiB
Python

"""Spawn-isolated strategy backtest and optimizer task runner."""
from __future__ import annotations
import json
import multiprocessing as mp
import os
import queue
import threading
import time
import traceback
from collections.abc import Callable
from contextlib import suppress
from dataclasses import asdict
from datetime import date
from pathlib import Path
from typing import Any
import psutil
class BacktestWorkerError(RuntimeError):
"""Raised when a spawned worker fails before returning a task result."""
_CANCEL_GRACE_SECONDS = 5.0
class _PeakRssSampler:
"""Track whole-task and resettable phase RSS peaks with one sampling thread."""
def __init__(self, interval_seconds: float = 0.05) -> None:
if interval_seconds <= 0:
raise ValueError("RSS sample interval must be positive")
self._process = psutil.Process(os.getpid())
self._interval_seconds = float(interval_seconds)
self._stop = threading.Event()
self._thread = threading.Thread(target=self._sample, daemon=True)
self._lock = threading.Lock()
self._started = False
current = int(self._process.memory_info().rss)
self.peak_rss_bytes = current
self._phase_peak_rss_bytes = current
def start(self) -> None:
if self._started:
raise RuntimeError("RSS sampler has already started")
self._started = True
self._thread.start()
def stop(self) -> int:
if self._started:
self._stop.set()
self._thread.join(timeout=1.0)
self._record_current()
return self.peak_rss_bytes
def reset_phase(self) -> None:
current = int(self._process.memory_info().rss)
with self._lock:
self._phase_peak_rss_bytes = current
def phase_peak_rss_bytes(self) -> int:
self._record_current()
with self._lock:
return self._phase_peak_rss_bytes
def _record_current(self) -> None:
current = int(self._process.memory_info().rss)
with self._lock:
self.peak_rss_bytes = max(self.peak_rss_bytes, current)
self._phase_peak_rss_bytes = max(self._phase_peak_rss_bytes, current)
def _sample(self) -> None:
while not self._stop.wait(self._interval_seconds):
self._record_current()
def _rss_bytes() -> int:
return int(psutil.Process(os.getpid()).memory_info().rss)
def _strategy_dirs(data_dir: Path) -> list[Path]:
app_dir = Path(__file__).resolve().parents[1]
return [
app_dir / "strategy" / "builtin",
data_dir / "strategies" / "custom",
data_dir / "strategies" / "ai",
data_dir / "strategies" / "composite",
]
def _decode_backtest_config(payload: dict[str, Any]):
from app.backtest.strategy import StrategyBacktestConfig
values = dict(payload)
values["start"] = date.fromisoformat(values["start"])
values["end"] = date.fromisoformat(values["end"])
return StrategyBacktestConfig(**values)
def _decode_optimize_config(payload: dict[str, Any]):
from app.backtest.optimizer import OptimizeConfig
values = dict(payload)
values["start"] = date.fromisoformat(values["start"])
values["end"] = date.fromisoformat(values["end"])
return OptimizeConfig(**values)
def _decode_walkforward_config(payload: dict[str, Any]):
from app.backtest.walkforward import WalkForwardConfig
values = dict(payload)
values["start"] = date.fromisoformat(values["start"])
values["end"] = date.fromisoformat(values["end"])
return WalkForwardConfig(**values)
def encode_backtest_config(config) -> dict[str, Any]:
payload = asdict(config)
payload["start"] = config.start.isoformat()
payload["end"] = config.end.isoformat()
return payload
def encode_optimize_config(config) -> dict[str, Any]:
payload = asdict(config)
payload["start"] = config.start.isoformat()
payload["end"] = config.end.isoformat()
return payload
def make_worker_task(kind: str, data_dir: Path, config) -> dict[str, Any]:
if kind == "backtest":
encoded = encode_backtest_config(config)
elif kind == "optimize":
encoded = encode_optimize_config(config)
elif kind == "walkforward":
encoded = asdict(config)
encoded["start"] = config.start.isoformat()
encoded["end"] = config.end.isoformat()
elif kind == "mining":
if not isinstance(config, dict):
raise TypeError("mining worker config must be a dict")
encoded = dict(config)
else:
raise ValueError(f"unsupported worker task kind: {kind}")
return {
"kind": kind,
"data_dir": str(data_dir.resolve()),
"config": encoded,
}
def _attach_worker_metrics(
kind: str,
result: dict[str, Any],
metrics: dict[str, Any],
) -> None:
if kind == "backtest":
result.setdefault("stats", {})["worker"] = metrics
else:
result["worker"] = metrics
def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None:
sampler = _PeakRssSampler()
sampler.start()
started = time.perf_counter()
store = None
try:
from app.backtest.engine import BacktestEngine
from app.backtest.optimizer import StrategyOptimizer
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
data_dir = Path(task["data_dir"])
store = DataStore(data_dir)
repo = KlineRepository(store)
strategy_engine = StrategyEngine(
strategy_dirs=_strategy_dirs(data_dir),
override_loader=lambda sid: strategy_config.load_override(data_dir, sid),
)
service = StrategyBacktestService(BacktestEngine(repo), strategy_engine)
def _progress(message: dict) -> None:
event_queue.put({"type": "progress", "payload": message})
kind = task["kind"]
if kind == "backtest":
config = _decode_backtest_config(task["config"])
result = asdict(service.run(config, _progress, cancel_event))
elif kind == "optimize":
config = _decode_optimize_config(task["config"])
optimizer = StrategyOptimizer(service, strategy_engine)
result = optimizer.optimize(
config,
_progress,
cancel_event,
rss_sampler=sampler,
)
elif kind == "walkforward":
from app.backtest.walkforward import WalkForwardService
config = _decode_walkforward_config(task["config"])
optimizer = StrategyOptimizer(service, strategy_engine)
walkforward = WalkForwardService(optimizer, service, strategy_engine)
result = walkforward.run(config, _progress, cancel_event)
elif kind == "mining":
from app.backtest.mining_runtime import run_mining_runtime
result = run_mining_runtime(
task["config"],
data_dir=data_dir,
service=service,
strategy_engine=strategy_engine,
progress_cb=_progress,
cancel_check=cancel_event,
rss_sampler=sampler,
)
else:
raise ValueError(f"unsupported worker task kind: {kind}")
serialization_started = time.perf_counter()
serialized_bytes = len(
json.dumps(result, ensure_ascii=False, default=str).encode("utf-8")
)
serialization_ms = round(
(time.perf_counter() - serialization_started) * 1000,
1,
)
peak_rss = sampler.stop()
metrics = {
"pid": os.getpid(),
"peak_rss_bytes": peak_rss,
"final_rss_bytes": _rss_bytes(),
"serialization_ms": serialization_ms,
"serialized_result_bytes": serialized_bytes,
"task_elapsed_ms": round((time.perf_counter() - started) * 1000, 1),
}
_attach_worker_metrics(kind, result, metrics)
event_queue.put({"type": "result", "payload": result})
except BaseException as exc:
with suppress(Exception):
sampler.stop()
event_queue.put({
"type": "error",
"message": str(exc),
"traceback": traceback.format_exc(),
})
finally:
if store is not None:
with suppress(Exception):
store.db.close()
def run_worker_task(
task: dict[str, Any],
progress_cb: Callable[[dict], None] | None = None,
cancel_event: threading.Event | None = None,
) -> dict[str, Any]:
"""Run one complete task in a spawned process and wait for deterministic exit."""
context = mp.get_context("spawn")
events = context.Queue()
process_cancel = context.Event()
process = context.Process(
target=_worker_entry,
args=(task, events, process_cancel),
daemon=False,
)
parent_rss_before = _rss_bytes()
try:
process.start()
except BaseException:
events.close()
events.join_thread()
raise
result: dict[str, Any] | None = None
failure: dict[str, Any] | None = None
ipc_started = time.perf_counter()
cancel_started: float | None = None
try:
while result is None and failure is None:
if cancel_event is not None and cancel_event.is_set():
process_cancel.set()
if cancel_started is None:
cancel_started = time.monotonic()
elif time.monotonic() - cancel_started >= _CANCEL_GRACE_SECONDS:
process.terminate()
process.join(timeout=5.0)
raise BacktestWorkerError(
"backtest worker did not stop within 5 seconds after cancellation"
)
try:
message = events.get(timeout=0.1)
except queue.Empty:
if not process.is_alive():
break
continue
message_type = message.get("type")
if message_type == "progress":
if progress_cb is not None:
progress_cb(message["payload"])
elif message_type == "result":
result = message["payload"]
elif message_type == "error":
failure = message
process.join(timeout=10.0)
if process.is_alive():
process.terminate()
process.join(timeout=5.0)
raise BacktestWorkerError("backtest worker returned but did not exit within 10 seconds")
if failure is not None:
raise BacktestWorkerError(
f"{failure.get('message', 'worker failed')}\n{failure.get('traceback', '')}".rstrip()
)
if result is None:
raise BacktestWorkerError(
f"backtest worker exited without result (exitcode={process.exitcode})"
)
parent_metrics = {
"ipc_elapsed_ms": round((time.perf_counter() - ipc_started) * 1000, 1),
"parent_rss_before_bytes": parent_rss_before,
"parent_rss_after_worker_exit_bytes": _rss_bytes(),
"worker_exitcode": process.exitcode,
}
kind = task["kind"]
if kind == "backtest":
result.setdefault("stats", {}).setdefault("worker", {}).update(parent_metrics)
else:
result.setdefault("worker", {}).update(parent_metrics)
return result
finally:
if process.is_alive():
process.terminate()
process.join(timeout=5.0)
events.close()
events.join_thread()