Files
tick-stock-panel/backend/app/backtest/worker.py
T
shy3130 4696fef959 feat(composite): 新增叠加策略(composite)支持选股与回测
引入第四种执行后端 composite: 声明式引用多个子策略, 复用现有注册表/缓存/撮合全链路。

核心设计:
- 退出采用来源投影(每个子策略 exit 仅在自己持仓窗口生效, 不串平其他子策略仓位)
- 评分用标准化排名加权(跨子策略可比, 不依赖 per-strategy 的 min-max 量纲)
- 来源归因(entry_signal_code 标记来源子策略)

后端:
- engine.py: CompositeSpec 数据模型, 两阶段引用校验(孤儿移除/嵌套禁止/asset_types 子集/≤8 上限), _run_composite_strategy 选股执行, find_dependents 删除防护, override_loader 注入保证子策略与单独跑同口径
- composite.py(新): 选股 merge_results + 回测 merge_signal_matrices 合并器(退出投影/排名融合/归因)
- backtest/strategy.py: composite 回测分支(特征计划合并/逐子信号/统一风控/basic_filter/归因)
- api/strategy.py: POST /composite/save 端点, _strategy_detail 返回 composite_children(含 name/source), 删除依赖 409 防护
- monitor.py: 实时监控 fail-closed(回退盘后缓存)
- walkforward.py: 对 composite 显式中文报错
- worker.py: _strategy_dirs 补 composite 目录(修复回测子进程找不到 composite 的 bug)

前端:
- CompositeStrategyDialog(新): 创建/编辑弹窗(自动生成 composite_ ID, 权重归一, 类型标签, 增删子策略)
- StrategySettingsDialog: composite 设置面板(子策略增删改/归一/类型标签)
- StrategyBacktest: 回测页 composite 适配(分组/参数区/结果区展示子策略构成)
- api.ts: 类型扩展(source/backend 加 composite, composite_children 含 name/source)

测试: 36 个 composite 专项测试(加载/选股/回测退出投影/排名/归因/override 透传/API/删除防护), 端到端验证真实内置策略回测通过
2026-08-02 10:56:03 +08:00

317 lines
10 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."""
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()
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.engine import StrategyEngine
from app.strategy import config as strategy_config
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)
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()
try:
while result is None and failure is None:
if cancel_event is not None and cancel_event.is_set():
process_cancel.set()
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()