Files
sanguo_vnpy_v2/docs/superpowers/plans/2026-07-05-phase2-factor-backtest.md
T

784 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 2 因子/回测层 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 构建因子层(vnpy.alpha 封装)+ CTA 回测层 + 任务编排 + 轻量 FastAPI,形成因子研究与策略回测的完整闭环。
**Architecture:** 分层解耦(sanguo_factor / sanguo_backtest / sanguo_orchestrator / sanguo_api),vnpy 引擎零改造,适配层只包装。因子层封装 vnpy.alpha 的 AlphaLab/AlphaDatasetpolars + 表达式因子 + 内置 alphalens);CTA 回测层包装 BacktestingEngine + run_optimization;编排层 multiprocessing poolAPI 层 5 路由 FastAPI。
**Tech Stack:** vnpy 4.4.0(源码 vnpy_v4.4.0/sys.path.insert)、vnpy.alphapolars + alphalens 内置)、vnpy_ctabacktesterrun_optimization)、FastAPI、pytest、Python 3.14。
## Global Constraints
- **vnpy 零改造**vnpy 源码在 `vnpy_v4.4.0/`,通过 `sys.path.insert(0, _VNPY_SRC)` 引用(参照 sanguo_data/datareader.py:6-9
- **Python 3.14**Phase 1 已验证 + vnpy 4.4.0 不拉 Qt
- **vnpy.alpha 真实 API**(源码探测):
- `AlphaLab`lab.py):polars,数据存 `lab_path/daily/{vt_symbol}.parquet`
- `AlphaDataset`dataset/template.py):表达式因子,`add_feature(name, expression)``feature_expressions: dict[str, str | pl.Expr]`
- 因子范式:`cs_rank(feature: DataProxy) -> DataProxy`(横截面)、ts_function、ta_function
- 内置 alphalens`from alphalens.tears import create_full_tear_sheet`template.py:14
- **数据转换必需**Phase 1SQLite DbBarData + parquet 年分区)→ AlphaLabpolars parquet by vt_symbol),转换在 `sanguo_factor/data_adapter.py`
- **run_optimization**:在 `vnpy_ctabacktester` 包(非 vnpy 核心),需 `pip install`S2 spike 验证
- **Phase 1 接口复用**`read_db_daily(symbol, start, end, cfg) → list[BarData]`
- **因子层范围**:只到 Alphalens 分层分析(IC/IR/分层收益),不含组合回测
- **测试覆盖率 ≥ 80%**TDD,每 task commit
- **不引 Qlib、不用 Celery**
---
## File Structure
```
sanguo_factor/{__init__,data_adapter,alpha_lab,registry,library,analyzer}.py
sanguo_backtest/{__init__,result_store,cta_engine,cta_optimizer}.py
sanguo_orchestrator/{__init__,task,pool,runner}.py
sanguo_api/{__init__,schemas,app,routes}.py
tests/{factor,backtest,orchestrator,api,spikes}/
config/backtest.yaml
scripts/smoke_phase2.py
```
---
## Task 1: S3 Spike — peewee 版本冲突
**Files:** `tests/spikes/test_s3_peewee.py`, `scripts/spike_s3_peewee.py`
**验证**alphalensvnpy.alpha 内置 import)与 vnpy_sqlitepeewee 4.1.1)能否共存。
- [ ] **Step 1: 写 spike 脚本**
```python
# scripts/spike_s3_peewee.py
"""S3: 验证 alphalens 与 vnpy_sqlite(peewee) 能否共存."""
import sys, os
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "vnpy_v4.4.0"))
sys.path.insert(0, _VNPY_SRC)
def main():
results = {}
try:
from alphalens.utils import get_clean_factor_and_forward_returns
from alphalens.tears import create_full_tear_sheet
results["alphalens_import"] = "OK"
except Exception as e:
results["alphalens_import"] = f"FAIL: {type(e).__name__}: {e}"
try:
import peewee
results["peewee_version"] = peewee.__version__
from vnpy_sqlite.sqlite_database import SqliteDatabase
results["vnpy_sqlite_import"] = "OK"
except Exception as e:
results["vnpy_sqlite_import"] = f"FAIL: {type(e).__name__}: {e}"
try:
from vnpy.alpha.dataset import AlphaDataset
results["alpha_dataset_import"] = "OK"
except Exception as e:
results["alpha_dataset_import"] = f"FAIL: {type(e).__name__}: {e}"
print("=== S3 Spike Result ===")
for k, v in results.items(): print(f" {k}: {v}")
ok = all(v == "OK" or v.startswith("4") for v in results.values())
print(f"\nS3 VERDICT: {'PASS' if ok else 'FAIL/CONFLICT'}")
if __name__ == "__main__":
main()
```
- [ ] **Step 2: 运行**`python scripts/spike_s3_peewee.py`
- [ ] **Step 3: 测试占位**
```python
# tests/spikes/test_s3_peewee.py
import pytest
def test_s3_verdict_recorded():
S3_VERDICT = "PENDING_RUN_SPIKE" # 改 PASS/FAIL 后提交
if S3_VERDICT == "PENDING_RUN_SPIKE":
pytest.skip("先运行 scripts/spike_s3_peewee.py,再填结论")
assert S3_VERDICT in ("PASS", "FAIL")
```
- [ ] **Step 4: 运行 spike + 记录结论到 progress.md**
- [ ] **Step 5: Commit**`git add tests/spikes/test_s3_peewee.py scripts/spike_s3_peewee.py && git commit -m "spike(s3): peewee 版本冲突验证"`
---
## Task 2: S1 Spike — vnpy.alpha A 股支撑度
**Files:** `tests/spikes/test_s1_vnpy_alpha_ashare.py`, `scripts/spike_s1_alpha_ashare.py`
**验证**AlphaLab/AlphaDataset 能否处理 A 股(Phase 1 真实数据 → AlphaLab 格式 → 表达式因子)。
- [ ] **Step 1: 写 spike 脚本**
```python
# scripts/spike_s1_alpha_ashare.py
"""S1: 验证 vnpy.alpha 能否处理 A 股数据。"""
import sys, os
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "vnpy_v4.4.0"))
sys.path.insert(0, _VNPY_SRC)
sys.path.insert(0, "/app")
def main():
results = {}
try:
from vnpy.alpha.lab import AlphaLab
AlphaLab("/tmp/alpha_lab_spike")
results["alphalab_init"] = "OK"
except Exception as e:
results["alphalab_init"] = f"FAIL: {e}"; print(results); return
try:
from sanguo_data.datareader import read_db_daily
from sanguo_data.config import load_config
cfg = load_config("/app/config/data_platform.yaml")
bars = read_db_daily("600000", "2024-01-01", "2024-06-30", cfg)
from vnpy.alpha.lab import AlphaLab
lab = AlphaLab("/tmp/alpha_lab_spike")
lab.save_bar_data(bars)
results["data_convert"] = f"OK ({len(bars)} bars)"
except Exception as e:
import traceback; traceback.print_exc()
results["data_convert"] = f"FAIL: {e}"; print(results); return
try:
from vnpy.alpha.dataset import AlphaDataset, Segment
results["alpha_dataset"] = "OK (需据源码细化 add_feature)"
except Exception as e:
results["alpha_dataset"] = f"FAIL: {e}"
print("=== S1 Spike Result ===")
for k, v in results.items(): print(f" {k}: {v}")
ok = all("OK" in v for v in results.values())
print(f"\nS1 VERDICT: {'PASS' if ok else 'PARTIAL/FAIL'}")
if __name__ == "__main__":
main()
```
- [ ] **Step 2: 容器运行**`docker run --rm -v /volume1/stock:/app/data sanguo_vnpy_v2:with-sqlite python /app/scripts/spike_s1_alpha_ashare.py`
- [ ] **Step 3: 测试占位**(同 S3 模式,findings dict 记录 A 股特性支撑度)
- [ ] **Step 4: 运行 + 记录**PARTIAL/FAIL → fallback 自建因子层)
- [ ] **Step 5: Commit**`git commit -m "spike(s1): vnpy.alpha A 股支撑度验证"`
---
## Task 3: S2 Spike — run_optimization 多进程
**Files:** `tests/spikes/test_s2_run_optimization.py`, `scripts/spike_s2_optimization.py`
**验证**vnpy_ctabacktester run_optimization 容器内 multiprocessing 行为 + max_workers。
- [ ] **Step 1: 装 vnpy_ctabacktester + 写 spike**(探测 run_optimization 签名 + 小规模跑)
- [ ] **Step 2: 容器 `pip install vnpy_ctabacktester` + 运行**NAS 弱 CPU 慢 → docker commit `:with-backtester`
- [ ] **Step 3: 测试占位**
- [ ] **Step 4: 记录 max_workers 推荐值**
- [ ] **Step 5: Commit**`git commit -m "spike(s2): run_optimization 多进程容器验证"`
> spike 脚本骨架参照 S3import + inspect.signature + 小规模调用),执行 subagent 据 run_optimization 实际签名填充。
---
## Task 4: sanguo_factor/data_adapter.py
**Files:** `sanguo_factor/__init__.py`, `sanguo_factor/data_adapter.py`, `tests/factor/conftest.py`, `tests/factor/test_data_adapter.py`
**Interfaces:** Consumes `read_db_daily`; Produces `convert_bars_to_alpha_df(bars) → pl.DataFrame``save_alpha_lab_data(bars, lab_path)`
- [ ] **Step 1: 写失败测试**
```python
# tests/factor/test_data_adapter.py
import sys, os
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0"))
sys.path.insert(0, _VNPY_SRC)
import polars as pl
from datetime import datetime
from vnpy.trader.object import BarData
from vnpy.trader.constant import Exchange, Interval
def _make_bar(symbol, dt, close):
return BarData(symbol=symbol, exchange=Exchange.SSE, datetime=dt,
interval=Interval.DAILY, open_price=close, high_price=close,
low_price=close, close_price=close, volume=1000, gateway_name="TEST")
def test_convert_bars_to_alpha_df_columns():
bars = [_make_bar("600000", datetime(2024,1,i), 10.0+i) for i in range(1, 6)]
from sanguo_factor.data_adapter import convert_bars_to_alpha_df
df = convert_bars_to_alpha_df(bars)
assert isinstance(df, pl.DataFrame)
for col in ["vt_symbol","datetime","open_price","high_price","low_price",
"close_price","volume","turnover","open_interest"]:
assert col in df.columns
assert df.height == 5
def test_convert_empty_bars():
from sanguo_factor.data_adapter import convert_bars_to_alpha_df
assert convert_bars_to_alpha_df([]).height == 0
```
- [ ] **Step 2: 验证失败**
- [ ] **Step 3: 实现**
```python
# sanguo_factor/data_adapter.py
"""Phase 1 BarData → vnpy.alpha AlphaLab polars 格式转换。"""
import sys, os
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "vnpy_v4.4.0"))
if _VNPY_SRC not in sys.path:
sys.path.insert(0, _VNPY_SRC)
import polars as pl
from pathlib import Path
from vnpy.trader.object import BarData
def convert_bars_to_alpha_df(bars: list[BarData]) -> pl.DataFrame:
if not bars:
return pl.DataFrame(schema={
"vt_symbol": pl.Utf8, "datetime": pl.Datetime,
"open_price": pl.Float64, "high_price": pl.Float64, "low_price": pl.Float64,
"close_price": pl.Float64, "volume": pl.Float64,
"turnover": pl.Float64, "open_interest": pl.Float64,
})
return pl.DataFrame({
"vt_symbol": [b.vt_symbol for b in bars],
"datetime": [b.datetime for b in bars],
"open_price": [b.open_price for b in bars],
"high_price": [b.high_price for b in bars],
"low_price": [b.low_price for b in bars],
"close_price": [b.close_price for b in bars],
"volume": [b.volume for b in bars],
"turnover": [b.turnover for b in bars],
"open_interest": [b.open_interest for b in bars],
})
def save_alpha_lab_data(bars: list[BarData], lab_path: str) -> Path:
from vnpy.alpha.lab import AlphaLab
lab = AlphaLab(lab_path)
lab.save_bar_data(bars)
return lab.daily_path
```
- [ ] **Step 4: 验证通过** — 2 passed
- [ ] **Step 5: Commit**`git commit -m "feat(factor): data_adapter BarData→AlphaLab polars 转换"`
---
## Task 5: sanguo_factor/registry.py + library.py
**Files:** `sanguo_factor/registry.py`, `sanguo_factor/library.py`, `tests/factor/test_registry.py`, `tests/factor/test_library.py`
**Interfaces:** `register_factor(name, expression, category)``get_factor(name)``list_factors(category=None)`
- [ ] **Step 1: 写测试**
```python
# tests/factor/test_registry.py
def test_register_and_get_factor():
from sanguo_factor.registry import register_factor, get_factor, _REGISTRY
_REGISTRY.clear()
register_factor("ma5", "pl.col('close_price').rolling_mean(5)", category="trend")
f = get_factor("ma5")
assert f["name"] == "ma5" and f["category"] == "trend"
def test_list_factors_by_category():
from sanguo_factor.registry import register_factor, list_factors, _REGISTRY
_REGISTRY.clear()
register_factor("ma5", "e1", category="trend")
register_factor("rsi", "e2", category="momentum")
assert len(list_factors(category="trend")) == 1
assert len(list_factors()) == 2
def test_register_duplicate_raises():
import pytest
from sanguo_factor.registry import register_factor, _REGISTRY
_REGISTRY.clear()
register_factor("ma5", "e", category="trend")
with pytest.raises(ValueError):
register_factor("ma5", "other", category="trend")
```
```python
# tests/factor/test_library.py
def test_library_registers_on_import():
from sanguo_factor.registry import list_factors
import sanguo_factor.library
assert len(list_factors(category="builtin")) > 0
```
- [ ] **Step 2: 验证失败**
- [ ] **Step 3: 实现**
```python
# sanguo_factor/registry.py
"""因子表达式注册(包装 vnpy.alpha AlphaDataset.add_feature 范式)。"""
from typing import Any
_REGISTRY: dict[str, dict[str, Any]] = {}
def register_factor(name: str, expression: str, category: str = "custom") -> None:
if name in _REGISTRY:
raise ValueError(f"因子已存在: {name}")
_REGISTRY[name] = {"name": name, "expression": expression, "category": category}
def get_factor(name: str) -> dict[str, Any] | None:
return _REGISTRY.get(name)
def list_factors(category: str | None = None) -> list[dict[str, Any]]:
if category is None:
return list(_REGISTRY.values())
return [f for f in _REGISTRY.values() if f["category"] == category]
```
```python
# sanguo_factor/library.py
"""内置因子表达式库(Alpha158/101 子集,polars 表达式)。"""
from .registry import register_factor
BUILTIN_FACTORS = [
{"name": "ma5", "expression": "pl.col('close_price').rolling_mean(5)", "category": "builtin"},
{"name": "ma10", "expression": "pl.col('close_price').rolling_mean(10)", "category": "builtin"},
{"name": "ma20", "expression": "pl.col('close_price').rolling_mean(20)", "category": "builtin"},
{"name": "vol_ma5", "expression": "pl.col('volume').rolling_mean(5)", "category": "builtin"},
{"name": "return_1d", "expression": "pl.col('close_price').pct_change(1)", "category": "builtin"},
{"name": "return_5d", "expression": "pl.col('close_price').pct_change(5)", "category": "builtin"},
{"name": "high_low_5", "expression": "(pl.col('high_price').rolling_max(5) - pl.col('low_price').rolling_min(5)) / pl.col('close_price')", "category": "builtin"},
]
def _register_all():
from .registry import _REGISTRY
for f in BUILTIN_FACTORS:
if f["name"] not in _REGISTRY:
register_factor(f["name"], f["expression"], f["category"])
_register_all()
```
- [ ] **Step 4: 验证通过**
- [ ] **Step 5: Commit**`git commit -m "feat(factor): 因子表达式注册 + Alpha158 子集内置库"`
---
## Task 6: sanguo_factor/alpha_lab.py + analyzer.py
**Files:** `sanguo_factor/alpha_lab.py`, `sanguo_factor/analyzer.py`, `tests/factor/test_alpha_lab.py`, `tests/factor/test_analyzer.py`
**Interfaces:** `AlphaLabSession``run_factor_analysis(symbols, factor_names, start, end, cfg, output_dir) → FactorReport`
**依赖 S1 spike**:若 vnpy.alpha 不支持 A 股 → fallback 自建。
- [ ] **Step 1: 写测试(mock AlphaLab + alphalens**
```python
# tests/factor/test_alpha_lab.py
from unittest.mock import Mock, patch
def test_alpha_lab_session_init(tmp_path):
from sanguo_factor.alpha_lab import AlphaLabSession
with patch("sanguo_factor.alpha_lab.AlphaLab") as MockLab:
s = AlphaLabSession(lab_path=str(tmp_path))
assert s.lab_path == str(tmp_path)
MockLab.assert_called_once()
def test_load_symbols_calls_read_db_daily(tmp_path):
from sanguo_factor.alpha_lab import AlphaLabSession
with patch("sanguo_factor.alpha_lab.AlphaLab"), \
patch("sanguo_factor.alpha_lab.read_db_daily") as mock_read:
mock_read.return_value = []
s = AlphaLabSession(lab_path=str(tmp_path))
s.load_symbols(["600000"], "2024-01-01", "2024-06-30", cfg=Mock())
mock_read.assert_called()
```
```python
# tests/factor/test_analyzer.py
from unittest.mock import Mock, patch, MagicMock
def test_run_factor_analysis_returns_report(tmp_path):
from sanguo_factor.analyzer import run_factor_analysis
with patch("sanguo_factor.analyzer.create_full_tear_sheet"), \
patch("sanguo_factor.analyzer.get_clean_factor_and_forward_returns") as mc:
mc.return_value = MagicMock()
r = run_factor_analysis(symbols=["600000"], factor_names=["ma5"],
start="2024-01-01", end="2024-06-30", cfg=Mock(), output_dir=str(tmp_path))
assert r.factor_names == ["ma5"]
```
- [ ] **Step 2: 验证失败**
- [ ] **Step 3: 实现**
```python
# sanguo_factor/alpha_lab.py
import sys, os
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "vnpy_v4.4.0"))
if _VNPY_SRC not in sys.path:
sys.path.insert(0, _VNPY_SRC)
from vnpy.alpha.lab import AlphaLab
from sanguo_data.datareader import read_db_daily
from .data_adapter import save_alpha_lab_data
class AlphaLabSession:
def __init__(self, lab_path: str):
self.lab_path = lab_path
self.lab = AlphaLab(lab_path)
def load_symbols(self, symbols: list[str], start: str, end: str, cfg) -> None:
for symbol in symbols:
bars = read_db_daily(symbol, start, end, cfg)
if bars:
save_alpha_lab_data(bars, self.lab_path)
```
```python
# sanguo_factor/analyzer.py
import sys, os
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "vnpy_v4.4.0"))
if _VNPY_SRC not in sys.path:
sys.path.insert(0, _VNPY_SRC)
from dataclasses import dataclass, field
from alphalens.utils import get_clean_factor_and_forward_returns
from alphalens.tears import create_full_tear_sheet
from .alpha_lab import AlphaLabSession
from .registry import get_factor
@dataclass
class FactorReport:
factor_names: list[str]
output_dir: str
ic_summary: dict = field(default_factory=dict)
def run_factor_analysis(symbols, factor_names, start, end, cfg, output_dir) -> FactorReport:
session = AlphaLabSession(lab_path=output_dir)
session.load_symbols(symbols, start, end, cfg)
# 执行时据 AlphaDataset.add_featuretemplate.py:60+ S1 spike 细化:
# for name in factor_names: dataset.add_feature(name, get_factor(name)["expression"])
# → get_clean_factor_and_forward_returns → create_full_tear_sheet
return FactorReport(factor_names=factor_names, output_dir=output_dir)
```
- [ ] **Step 4: 验证通过**
- [ ] **Step 5: Commit**`git commit -m "feat(factor): AlphaLab 封装 + Alphalens 分析器"`
---
## Task 7: sanguo_backtest/result_store.py
**Files:** `sanguo_backtest/__init__.py`, `sanguo_backtest/result_store.py`, `tests/backtest/conftest.py`, `tests/backtest/test_result_store.py`
**Interfaces:** `BacktestResult` dataclass、`save_result(result, db_path, file_dir) → int``load_result(id, db_path)``list_results(type_filter, db_path)`
- [ ] **Step 1: 写测试**save/load、list filter、failed error_msg 三例,完整代码见 design §2.2)
- [ ] **Step 2: 验证失败**
- [ ] **Step 3: 实现**SQLite `backtest_stats` 表 + parquet 净值/成交,schema 含 `id, task_id, type, status, strategy, symbol, params(json), start, end, statistics(json), equity_path, trades_path, error_msg, created_at`
```python
# sanguo_backtest/result_store.py
import sqlite3, json
from dataclasses import dataclass
from pathlib import Path
import pandas as pd
@dataclass
class BacktestResult:
task_id: str; type: str; status: str; strategy: str; symbol: str
params: dict; start: str; end: str; statistics: dict
equity_curve: "pd.DataFrame | None" = None
trades: "pd.DataFrame | None" = None
error_msg: "str | None" = None
_SCHEMA = """CREATE TABLE IF NOT EXISTS backtest_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT, type TEXT, status TEXT, strategy TEXT, symbol TEXT,
params TEXT, start TEXT, end TEXT, statistics TEXT,
equity_path TEXT, trades_path TEXT, error_msg TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"""
def _connect(db_path):
conn = sqlite3.connect(db_path); conn.executescript(_SCHEMA); return conn
def save_result(result, db_path, file_dir=None):
conn = _connect(db_path)
try:
equity_path = trades_path = None
if file_dir:
fdir = Path(file_dir); fdir.mkdir(parents=True, exist_ok=True)
if result.equity_curve is not None and not result.equity_curve.empty:
equity_path = str(fdir / f"{result.task_id}_equity.parquet")
result.equity_curve.to_parquet(equity_path)
if result.trades is not None and not result.trades.empty:
trades_path = str(fdir / f"{result.task_id}_trades.parquet")
result.trades.to_parquet(trades_path)
cur = conn.execute(
"INSERT INTO backtest_stats (task_id,type,status,strategy,symbol,params,start,end,statistics,equity_path,trades_path,error_msg) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(result.task_id, result.type, result.status, result.strategy, result.symbol,
json.dumps(result.params), result.start, result.end,
json.dumps(result.statistics), equity_path, trades_path, result.error_msg))
conn.commit(); return cur.lastrowid
finally:
conn.close()
def load_result(rid, db_path):
conn = _connect(db_path)
try:
row = conn.execute("SELECT * FROM backtest_stats WHERE id=?", (rid,)).fetchone()
if not row: raise KeyError(f"result {rid} not found")
cols = [d[0] for d in conn.execute("SELECT * FROM backtest_stats LIMIT 0").description]
d = dict(zip(cols, row))
equity = pd.read_parquet(d["equity_path"]) if d.get("equity_path") else None
trades = pd.read_parquet(d["trades_path"]) if d.get("trades_path") else None
return BacktestResult(
task_id=d["task_id"], type=d["type"], status=d["status"],
strategy=d["strategy"], symbol=d["symbol"],
params=json.loads(d["params"]), start=d["start"], end=d["end"],
statistics=json.loads(d["statistics"]) if d["statistics"] else {},
equity_curve=equity, trades=trades, error_msg=d.get("error_msg"))
finally:
conn.close()
def list_results(type_filter=None, db_path=""):
conn = _connect(db_path)
try:
q, args = "SELECT id FROM backtest_stats", ()
if type_filter: q, args = q + " WHERE type=?", (type_filter,)
return [load_result(r[0], db_path) for r in conn.execute(q, args).fetchall()]
finally:
conn.close()
```
- [ ] **Step 4: 验证通过** — 3 passed
- [ ] **Step 5: Commit**`git commit -m "feat(backtest): result_store SQLite+parquet 结果存储"`
---
## Task 8: sanguo_backtest/cta_engine.py
**Files:** `sanguo_backtest/cta_engine.py`, `tests/backtest/test_cta_engine.py`
**Interfaces:** `run_cta_backtest(strategy_class, symbol, params, start, end, cfg, db_path) → BacktestResult`
- [ ] **Step 1: 写测试**mock BacktestingEnginedone + failed 两例)
- [ ] **Step 2: 验证失败**
- [ ] **Step 3: 实现**(包装 `vnpy_ctabacktester.backtesting.BacktestingEngine``set_parameters/add_strategy/load_data/run_backtesting/calculate_result` 签名由执行 subagent 读源码 + S2 spike 填充;异常 → status=failed + error_msg
- [ ] **Step 4: 验证通过**
- [ ] **Step 5: Commit**`git commit -m "feat(backtest): cta_engine BacktestingEngine wrapper"`
---
## Task 9: sanguo_backtest/cta_optimizer.py
**Files:** `sanguo_backtest/cta_optimizer.py`, `tests/backtest/test_cta_optimizer.py`
**Interfaces:** `run_cta_optimization(strategy_class, symbol, grid, start, end, cfg, db_path, max_workers) → list[BacktestResult]`
- [ ] **Step 1: 写测试**mock run_optimization,多结果 + 失败两例)
- [ ] **Step 2-4**: 失败 → 实现(包装 `run_optimization``output=False` headless`max_workers` 来自 S2)→ 通过
- [ ] **Step 5: Commit**`git commit -m "feat(backtest): cta_optimizer run_optimization wrapper"`
---
## Task 10: sanguo_orchestrator/task.py + pool.py
**Files:** `sanguo_orchestrator/__init__.py`, `task.py`, `pool.py`, `tests/orchestrator/test_task.py`, `tests/orchestrator/test_pool.py`
- [ ] **Step 1: 写测试**TaskState 转换 PENDING→RUNNING→DONE/FAILED + TaskPool submit/get_status
- [ ] **Step 2-4**: 实现 `TaskState(enum)` + `Task(dataclass, start/complete/fail)` + `TaskPool(submit/get_status/get_task)` → 通过
- [ ] **Step 5: Commit**`git commit -m "feat(orchestrator): task 状态机 + pool 封装"`
```python
# sanguo_orchestrator/task.py
import enum
from dataclasses import dataclass
class TaskState(enum.Enum):
PENDING = "pending"; RUNNING = "running"; DONE = "done"; FAILED = "failed"
@dataclass
class Task:
task_id: str; task_type: str
status: TaskState = TaskState.PENDING
result_id: int | None = None
error_msg: str | None = None
def start(self):
if self.status != TaskState.PENDING: raise ValueError(f"不能从 {self.status} 启动")
self.status = TaskState.RUNNING
def complete(self, result_id: int):
self.status = TaskState.DONE; self.result_id = result_id
def fail(self, error_msg: str):
self.status = TaskState.FAILED; self.error_msg = error_msg
```
```python
# sanguo_orchestrator/pool.py
from .task import Task, TaskState
class TaskPool:
def __init__(self, max_workers: int = 2):
self.max_workers = max_workers
self._tasks: dict[str, Task] = {}
def submit(self, task_id, task_type):
t = Task(task_id=task_id, task_type=task_type); self._tasks[task_id] = t; return t
def get_status(self, task_id):
t = self._tasks.get(task_id); return t.status if t else None
def get_task(self, task_id): return self._tasks.get(task_id)
```
---
## Task 11: sanguo_orchestrator/runner.py
**Files:** `sanguo_orchestrator/runner.py`, `tests/orchestrator/test_runner.py`
**Interfaces:** `Orchestrator(db_path, file_dir, max_workers)` + `submit_cta/submit_optimize/submit_factor` + `get_status/get_result`
- [ ] **Step 1: 写测试**submit_cta + _run_sync + get_status==DONE
- [ ] **Step 2-4**: 实现 Orchestratorsubmit 创建 Task + 存 pending spec_run_sync 调 run_cta_backtestget_result 调 load_result)→ 通过
- [ ] **Step 5: Commit**`git commit -m "feat(orchestrator): runner 任务调度器"`
```python
# sanguo_orchestrator/runner.py
from .pool import TaskPool
from .task import TaskState
class Orchestrator:
def __init__(self, db_path, file_dir=None, max_workers=2):
self.db_path = db_path; self.file_dir = file_dir
self.pool = TaskPool(max_workers=max_workers); self._pending = {}
def submit_cta(self, strategy_class, symbol, params, start, end, cfg):
task_id = f"cta_{symbol}_{id(params)}"
self.pool.submit(task_id, "cta")
self._pending = dict(strategy_class=strategy_class, symbol=symbol,
params=params, start=start, end=end, cfg=cfg)
return task_id
def _run_sync(self, task_id):
from sanguo_backtest.cta_engine import run_cta_backtest
task = self.pool.get_task(task_id); task.start()
try:
result = run_cta_backtest(self._pending["strategy_class"], self._pending["symbol"],
self._pending["params"], self._pending["start"],
self._pending["end"], self._pending["cfg"], self.db_path)
task.complete(result_id=id(result))
except Exception as e:
task.fail(f"{type(e).__name__}: {e}")
return task
def get_status(self, task_id): return self.pool.get_status(task_id)
def get_result(self, task_id):
task = self.pool.get_task(task_id)
if task and task.status == TaskState.DONE and task.result_id:
from sanguo_backtest.result_store import load_result
return load_result(task.result_id, self.db_path)
return None
```
> submit_optimize / submit_factor 由执行 subagent 参照 submit_cta 扩展。
---
## Task 12: sanguo_api(轻量 FastAPI 5 路由)
**Files:** `sanguo_api/{__init__,schemas,app,routes}.py`, `config/backtest.yaml`, `tests/api/test_routes.py`
- [ ] **Step 1: 写测试**TestClientsubmit_cta 200、get_status done、缺字段 422
- [ ] **Step 2-4**: 实现 schemasPydantic+ appcreate_app+ routes5 路由)→ 通过
```python
# sanguo_api/schemas.py
from pydantic import BaseModel
class CtaBacktestRequest(BaseModel):
symbol: str; strategy: str; params: dict = {}; start: str; end: str
class OptimizeRequest(BaseModel):
symbol: str; strategy: str; grid: dict; start: str; end: str; max_workers: int = 2
class FactorAnalysisRequest(BaseModel):
symbols: list[str]; factor_names: list[str]; start: str; end: str
```
```python
# sanguo_api/app.py
from fastapi import FastAPI
from .routes import router, set_orchestrator
from sanguo_orchestrator.runner import Orchestrator
def create_app(db_path, file_dir=None):
app = FastAPI(title="Sanguo Quant API")
set_orchestrator(Orchestrator(db_path=db_path, file_dir=file_dir))
app.include_router(router, prefix="/api/v1")
return app
```
```python
# sanguo_api/routes.py
from fastapi import APIRouter, HTTPException
from .schemas import CtaBacktestRequest, OptimizeRequest, FactorAnalysisRequest
router = APIRouter(); _orchestrator = None
def set_orchestrator(orch):
global _orchestrator; _orchestrator = orch
def get_orchestrator(): return _orchestrator
@router.post("/backtest/cta")
def submit_cta(req: CtaBacktestRequest):
tid = get_orchestrator().submit_cta(strategy_class=req.strategy, symbol=req.symbol,
params=req.params, start=req.start, end=req.end, cfg=None)
return {"task_id": tid}
@router.post("/backtest/optimize")
def submit_optimize(req: OptimizeRequest):
# 执行时补:orch.submit_optimize(...)
return {"task_id": "pending_impl"}
@router.post("/factor/analyze")
def submit_factor(req: FactorAnalysisRequest):
# 执行时补:orch.submit_factor(...)
return {"task_id": "pending_impl"}
@router.get("/task/{task_id}")
def get_status(task_id: str):
s = get_orchestrator().get_status(task_id)
if s is None: raise HTTPException(404, "task not found")
return {"task_id": task_id, "status": s.value if hasattr(s, "value") else str(s)}
@router.get("/task/{task_id}/result")
def get_result(task_id: str):
r = get_orchestrator().get_result(task_id)
if r is None: raise HTTPException(404, "result not ready")
return {"task_id": task_id, "statistics": r.statistics}
```
```yaml
# config/backtest.yaml
backtest:
max_workers: 2
db_path: /volume1/stock/sanguo_vnpy/data/backtest_results.db
file_dir: /volume1/stock/sanguo_vnpy/data/backtest_files
api:
host: 0.0.0.0
port: 8000
```
- [ ] **Step 5: Commit**`git commit -m "feat(api): 轻量 FastAPI 5 路由(无 JWT/WS/前端)"`
> optimize/factor 路由的 `pending_impl` 由执行 subagent 补完。
---
## Task 13: 端到端冒烟 + 集成测试
**Files:** `scripts/smoke_phase2.py`, `tests/integration/test_phase2_e2e.py`
- [ ] **Step 1: 写冒烟脚本**result_store 增删查 + API 启动 + 因子 registry
- [ ] **Step 2: 集成测试 + 覆盖率**`pytest tests/ -v --cov=sanguo_factor --cov=sanguo_backtest --cov=sanguo_orchestrator --cov=sanguo_api --cov-report=term-missing` → 全绿 + ≥80%
- [ ] **Step 3: 容器端到端冒烟**(真实 vnpy
- [ ] **Step 4: Commit**`git commit -m "test(phase2): 端到端冒烟 + 集成测试(覆盖率 ≥80%)"`
- [ ] **Step 5: requesting-code-review + finishing-a-development-branch**
---
## Self-Review
**1. Spec coverage**:因子层(T4-6) + 回测层(T7-9) + 编排层(T10-11) + API(T12) + Spike(T1-3) + 集成(T13) = design §2-§6 全覆盖 ✅
**2. Placeholder**`pending_impl`routes optimize/factor)、cta_engine/optimizer vnpy API 细节(S2)、analyzer AlphaDataset 流程(S1)—— 均依赖 spike,已标注,非偷懒
**3. Type consistency**BacktestResult(T7) / Task+TaskState(T10) / Orchestrator(T11) 跨 task 一致 ✅
---
## Execution Handoff
**Plan saved to `docs/superpowers/plans/2026-07-05-phase2-factor-backtest.md`.**
按 /goal 授权,采用 **Subagent-Driven** 执行(superpowers:subagent-driven-development):每 task 派 fresh subagent + 两阶段 review。从 Task 1S3 spike)开始。