Files
sanguo_vnpy_v2/docs/superpowers/plans/2026-08-24-factor-batch1-alpha101-158.md
T

2216 lines
89 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.
# 因子研究第一批(Alpha101+Alpha158 挂载与全A重验)实施计划
> **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 自带的 Alpha101(100)+Alpha158(158) 因子表达式挂进 sanguo_factor 注册表,建成批量评估管线(全A 8.5 年 → IC 排行榜落盘)+ 前端「因子研究工作台」三页面,部署 NAS 验证。
**Architecture:** 评估引擎**不走** AlphaDataset 的 spawn 进程池(其 prepare_data 会对每个表达式 pickle 整个 9M 行 DataFrame),改为直接调用 `vnpy.alpha.dataset.utility.calculate_by_expression` 在进程内逐因子计算(内存天然有界,无 spawn 路径问题)。股票池由 dbbardata 直读(sqlite3 分块查询 → polars),vwap 在加载层派生(turnover/volume)。指标全部 numpy/pandas 向量化(RankIC=秩相关逐行向量化)。结果落 `factor_eval.db`eval_runs + eval_results 两表,metrics 按周期存 JSON)。现有单因子分析链路(AlphaLabSession/alphalens tears**零改动**。
**Tech Stack:** Python 3.10 (venv310) / polars 1.42 / pandas / sqlite3 / FastAPI / Vue3 + TS + Element Plus + echarts
**Spec:** `docs/superpowers/specs/2026-08-24-factor-batch1-alpha101-158.md`
**前端视觉唯一真相源:** `docs/factor_research/factor-workbench-mockup.html`(用户已确认)+ `frontend/src/styles/tokens.css`
## Global Constraints
- 测试一律用 `venv310/bin/python -m pytest ...`(系统 python 的 pandas 3.0.1 假红)
- commit message 末尾必打标签:后端/数据链路改动 `[vps]`(VPS 运行时也触发),纯前端/CI/docker `[nas]`
- 不改:`sanguo_factor/analyzer.py``alpha_lab.py` 现有行为、`routes.py` 既有 /factor/list /factor/analyze 端点、vnpy_v4.4.0/ 任何文件
- 新 API 路由放新文件 `sanguo_api/routes_factor.py`(仿 routes_paper.py 模式),在 `app.py` 注册
- 前端页面样式 1:1 对齐 mockup:深空黑 `--bg`、荧光青 `--brand`、琥珀 `--amber`、红涨绿跌 `--up/--down`、近直角、mono 数字;读 `frontend/src/styles/tokens.css` 取变量名,不写死色值
- 评估口径(spec 定稿):主板+创业板(代码前缀 60/00/30,剔科创68/北交/ETF)、2018-01-01~2026-06-30、RankIC 前瞻 1/5/10 日、退市股保留、不做行业中性化
- **⚠️ 对 spec 的一处有意识偏差**:不实现 ST 过滤。本地无 point-in-time 名称史,用「当前名称」回溯过滤会引入前视偏差,比不过滤更糟。代码前缀过滤 + 120 交易日预热已控住主要污染。在最终报告向用户说明
- dbbardata 列名:`symbol, exchange, datetime, interval, volume, turnover, open_interest, open_price, high_price, low_price, close_price`datetime 为 ISO 字符串,日线 interval='d'(既有直查范式见 `sanguo_backtest/ashare_engine.py:235`
- NAS 容器名 `sanguo_vnpy_v2`docker 全路径 `/var/packages/Docker/target/usr/bin/docker`
- YAGNI(spec 明确不做):行业中性化、因子合成、Alpha360、GTJA191、聚宽财务因子、相关性聚类去重代码化(跑完数据后再分析)
## File Structure
```
sanguo_factor/
data_adapter.py [改] convert_bars_to_alpha_df 补 vwap 派生列
alpha_datasets.py [新] 挂载 vnpy Alpha101/158 表达式进注册表
universe.py [新] dbbardata 分块直读 → AlphaLab 格式 polars df(含 vwap/bar_idx
metrics.py [新] 向量化 RankIC/ICIR/t/胜率/多空年化/换手/十分组
eval_store.py [新] factor_eval.db 读写(eval_runs/eval_results
batch_eval.py [新] 批量评估引擎(进程内逐因子算 → 指标 → 落盘)
sanguo_api/
schemas.py [改] +FactorBatchEvalRequest
routes_factor.py [新] GET runs/leaderboard/detail + POST submit
app.py [改] 注册 routes_factor + set_eval_db_path 接线
sanguo_orchestrator/
runner.py [改] +submit_batch_eval +_batch_eval_worker + _persist_factor_eval
scripts/factor_research/
run_eval.py [新] CLI(冒烟/全量,NAS docker exec 用)
frontend/src/
api/factor.ts [改] +评估相关类型与请求
router/index.ts [改] +3 路由
views/Layout.vue [改] 投研组 +2 菜单项 +面包屑
views/factor/Leaderboard.vue [新] IC 排行榜
views/factor/LeaderboardDetail.vue [新] 因子详情
views/factor/BatchEval.vue [新] 批量评估
tests/factor/
test_alpha_datasets.py [新]
test_vwap_adapter.py [新]
test_universe.py [新]
test_metrics.py [新]
test_eval_store.py [新]
test_batch_eval.py [新]
tests/api/
test_factor_eval_routes.py [新]
.gitea/workflows/ci-cd.yml [改] pytest 门禁加 tests/factor
```
---
### Task 1: vwap 派生列(data_adapter
**Files:**
- Modify: `sanguo_factor/data_adapter.py`
- Test: `tests/factor/test_vwap_adapter.py`
**Interfaces:**
- Produces: `convert_bars_to_alpha_df(bars) -> pl.DataFrame` 输出多一列 `vwap: Float64`volume>0 时 = turnover/volume,否则 null)。下游 Task 2/6 依赖「df 有 vwap 列即可被表达式引用」(`calculate_by_expression` 按 df.columns 自动注入变量名)。
- [ ] **Step 1: 写失败测试**
```python
# tests/factor/test_vwap_adapter.py
"""vwap 派生列:turnover/volume,volume=0 → null."""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0")))
from datetime import datetime
from vnpy.trader.object import BarData, Interval
from sanguo_factor.data_adapter import convert_bars_to_alpha_df
def _bar(symbol, dt, close=10.0, volume=100.0, turnover=1000.0):
return BarData(
symbol=symbol, exchange="SSE", datetime=dt, interval=Interval.DAILY,
open_price=9.9, high_price=10.1, low_price=9.8, close_price=close,
volume=volume, turnover=turnover, open_interest=0, gateway_name="T",
)
def test_vwap_derived():
df = convert_bars_to_alpha_df([_bar("600000", datetime(2024, 1, 5))])
assert df["vwap"][0] == 10.0 # 1000 / 100
def test_vwap_null_when_volume_zero():
df = convert_bars_to_alpha_df([_bar("600000", datetime(2024, 1, 5), volume=0.0, turnover=0.0)])
assert df["vwap"][0] is None
def test_empty_schema_has_vwap():
df = convert_bars_to_alpha_df([])
assert "vwap" in df.columns
assert df.height == 0
```
- [ ] **Step 2: 跑测试确认失败**
Run: `venv310/bin/python -m pytest tests/factor/test_vwap_adapter.py -v`
Expected: FAIL`vwap` 不在 columns
- [ ] **Step 3: 最小实现**
`convert_bars_to_alpha_df` 的空 schema dict 加 `"vwap": pl.Float64`;构造 dict 加:
```python
"vwap": [(b.turnover / b.volume) if (b.turnover and b.volume) else None for b in bars],
```
docstring 补一行:`vwap: turnover/volume 派生(volume=0 置 null),Alpha101 表达式依赖`
- [ ] **Step 4: 跑测试确认通过 + 回归**
Run: `venv310/bin/python -m pytest tests/factor -v`
Expected: 全 PASS(含既有 5 个测试文件)
- [ ] **Step 5: Commit**
```bash
git add sanguo_factor/data_adapter.py tests/factor/test_vwap_adapter.py
git commit -m "feat(factor): data_adapter 补 vwap 派生列(turnover/volume,零量置null)——Alpha101 表达式 59 处引用 vwap,dbbardata 无此列 [vps]"
```
---
### Task 2: 挂载 Alpha101/Alpha158 表达式(alpha_datasets
**Files:**
- Create: `sanguo_factor/alpha_datasets.py`
- Test: `tests/factor/test_alpha_datasets.py`
**Interfaces:**
- Consumes: `vnpy.alpha.dataset.datasets.alpha_101.Alpha101` / `alpha_158.Alpha158`(构造只注册表达式不计算,空 df 即可实例化)
- Produces:
- `mount_alpha101() -> int``mount_alpha158() -> int``mount_all() -> dict[str, int]`(幂等,已存在同名跳过)
- 注册后 `list_factors("alpha101")` 100 项、`list_factors("alpha158")` 158 项,factor 名保持 vnpy 原名(`alpha1..alpha100``kmid/klen/roc_5/ma_20/...`
- 挂载入口必须在 `sanguo_factor/__init__.py` 导入即生效(`from . import alpha_datasets` 链上自动注册)——下游 `/factor/list`、批量评估、CLI 全靠它
- [ ] **Step 1: 写失败测试**
```python
# tests/factor/test_alpha_datasets.py
"""Alpha101/158 全量挂载:数量/幂等/类别/表达式可用性."""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0")))
from sanguo_factor.alpha_datasets import mount_all
from sanguo_factor.registry import list_factors, get_factor, _REGISTRY
def test_mount_all_counts():
counts = mount_all()
assert counts == {"alpha101": 100, "alpha158": 158}
def test_categories_and_expression():
mount_all()
a101 = list_factors("alpha101")
assert {f["name"] for f in a101} >= {"alpha1", "alpha2", "alpha100"}
assert get_factor("alpha5")["expression"].count("vwap") >= 1 # alpha5 显式用 vwap
a158 = list_factors("alpha158")
names = {f["name"] for f in a158}
assert {"kmid", "klen", "roc_5", "ma_20", "std_20", "wvma_20", "vwap_0"} <= names
def test_mount_idempotent():
mount_all()
n_before = len(_REGISTRY)
mount_all()
assert len(_REGISTRY) == n_before
def test_import_side_effect_registers():
# sanguo_factor 包导入即挂载(下游 /factor/list 依赖)
import sanguo_factor
assert len(list_factors("alpha101")) == 100
```
- [ ] **Step 2: 跑测试确认失败**
Run: `venv310/bin/python -m pytest tests/factor/test_alpha_datasets.py -v`
Expected: FAILModuleNotFoundError: sanguo_factor.alpha_datasets
- [ ] **Step 3: 实现**
```python
# sanguo_factor/alpha_datasets.py
"""挂载 vnpy 自带 Alpha101/Alpha158 表达式因子到注册表.
vnpy 的 AlphaDataset 子类在 __init__ 里只做 add_feature 字符串注册(不触发计算),
用空 schema df 实例化即可零成本提取全部表达式。
"""
import sys
import 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 .registry import register_factor, list_factors, _REGISTRY
_ALPHA_SCHEMA = {
"vt_symbol": __import__("polars").Utf8,
"datetime": __import__("polars").Datetime,
"open": __import__("polars").Float64,
"high": __import__("polars").Float64,
"low": __import__("polars").Float64,
"close": __import__("polars").Float64,
"volume": __import__("polars").Float64,
"turnover": __import__("polars").Float64,
"open_interest": __import__("polars").Float64,
"vwap": __import__("polars").Float64,
}
_DUMMY_PERIOD = ("2018-01-01", "2018-01-02")
def _extract_expressions(dataset_cls) -> dict[str, str]:
"""实例化数据集类,提取其注册的全部 {name: expression}."""
import polars as pl
df = pl.DataFrame(schema=_ALPHA_SCHEMA)
ds = dataset_cls(df, _DUMMY_PERIOD, _DUMMY_PERIOD, _DUMMY_PERIOD)
return {name: str(expr) for name, expr in ds.feature_expressions.items()}
def _mount(dataset_cls, category: str) -> int:
"""把一个数据集类的表达式挂进注册表(已存在同名跳过,幂等)."""
mounted = 0
for name, expression in _extract_expressions(dataset_cls).items():
if name in _REGISTRY:
continue
register_factor(name, expression, category)
mounted += 1
return mounted
def mount_alpha101() -> int:
"""挂载 WorldQuant Alpha101(100 个),category=alpha101."""
from vnpy.alpha.dataset.datasets.alpha_101 import Alpha101
return _mount(Alpha101, "alpha101")
def mount_alpha158() -> int:
"""挂载 Qlib Alpha158(158 个),category=alpha158."""
from vnpy.alpha.dataset.datasets.alpha_158 import Alpha158
return _mount(Alpha158, "alpha158")
def mount_all() -> dict[str, int]:
"""挂载全部内置数据集,返回各类新挂数量."""
return {"alpha101": mount_alpha101(), "alpha158": mount_alpha158()}
# 模块导入即挂载(与 library.py 内置因子同一模式,下游 import sanguo_factor 即全量可见)
mount_all()
```
> 注:`__import__("polars")` 的写法太丑——实现时直接顶部 `import polars as pl`schema 用 `pl.Utf8` 等(polars 是硬依赖,无需 lazy)。上面为计划示意,**实现以顶部 import polars 为准**。
`sanguo_factor/__init__.py` 末尾追加一行:
```python
from . import alpha_datasets # noqa: F401 挂载 Alpha101/158(导入即注册)
```
(先读该文件现有内容,追加不重排。)
- [ ] **Step 4: 跑测试确认通过**
Run: `venv310/bin/python -m pytest tests/factor/test_alpha_datasets.py tests/factor -v`
Expected: 全 PASS
- [ ] **Step 5: Commit**
```bash
git add sanguo_factor/alpha_datasets.py sanguo_factor/__init__.py tests/factor/test_alpha_datasets.py
git commit -m "feat(factor): 挂载 vnpy Alpha101(100)+Alpha158(158) 全量表达式进注册表——空df实例化零成本提取,导入即注册,幂等 [vps]"
```
---
### Task 3: 评估股票池加载(universe
**Files:**
- Create: `sanguo_factor/universe.py`
- Test: `tests/factor/test_universe.py`
**Interfaces:**
- Consumes: dbbardata 表结构(见 Global Constraints
- Produces:
- `STOCK_PREFIXES = ("60", "00", "30")``WARMUP_BARS = 120`
- `load_universe_bars(vnpy_db: str, start: str, end: str, symbols: list[str] | None = None, limit: int | None = None) -> pl.DataFrame`
- start/end 为评估窗(YYYY-MM-DD);内部自动加 lookbackstart-300 天)与 forward 缓冲(end+45 天,供 10 日前瞻收益)
- 返回列:`vt_symbol(Utf8), datetime(Datetime, 已截断到日), open, high, low, close, volume, turnover, open_interest, vwap, bar_idx(Int64)`
- `vwap``volume>0 → turnover/volume 否则 null`
- `bar_idx`:按 symbol 分组、datetime 升序的 0 起行号(预热/次新过滤用)
- `limit`:随机抽样 N 只(`random.Random(42)` 固定种子,冒烟可复现);`symbols` 显式给定时只查这些
- `evaluation_filter(df: pl.DataFrame, start: str, end: str) -> pl.DataFrame``datetime ∈ [start, end] 且 bar_idx >= WARMUP_BARS`IC 评估行集)
- [ ] **Step 1: 写失败测试**
```python
# tests/factor/test_universe.py
"""股票池加载:前缀过滤/时间缓冲/vwap/bar_idx/显式symbols/limit抽样."""
import sqlite3
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0")))
import pytest
from sanguo_factor.universe import load_universe_bars, evaluation_filter, WARMUP_BARS
_DDL = """
CREATE TABLE dbbardata(
symbol TEXT, exchange TEXT, datetime TEXT, interval TEXT,
volume REAL, turnover REAL, open_interest REAL,
open_price REAL, high_price REAL, low_price REAL, close_price REAL)
"""
def _mk_db(tmp_path, rows):
db = str(tmp_path / "qt.db")
conn = sqlite3.connect(db)
conn.execute(_DDL)
conn.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows)
conn.commit(); conn.close()
return db
def _row(sym, ex, day, close, volume=100.0, turnover=None):
return (sym, ex, f"{day} 00:00:00", "d", volume,
turnover if turnover is not None else close * volume, 0,
close, close, close, close)
@pytest.fixture()
def db(tmp_path):
rows = []
# 600000:60根(2017-10~2017-12) + 评估窗内 3 根
for i in range(60):
rows.append(_row("600000", "SSE", f"2017-10-{(i % 28) + 1:02d}", 10.0 + i * 0.01))
for d in ("2018-01-02", "2018-01-03", "2018-01-04"):
rows.append(_row("600000", "SSE", d, 11.0))
# 000001:只有 2 根(次新,bar_idx<WARMUP 应被 evaluation_filter 排除)
for d in ("2018-01-02", "2018-01-03"):
rows.append(_row("000001", "SZSE", d, 5.0))
# 应剔除:科创68/北交8开头/ETF 510300
rows.append(_row("688001", "SSE", "2018-01-02", 20.0))
rows.append(_row("830001", "BJSE", "2018-01-02", 3.0))
rows.append(_row("510300", "SSE", "2018-01-02", 4.0))
# 非日线 interval 应忽略
rows.append(("600000", "SSE", "2018-01-02 09:35:00", "15m", 1, 1, 0, 1, 1, 1, 1))
return _mk_db(tmp_path, rows)
def test_load_filters_prefix_and_interval(db):
df = load_universe_bars(db, "2018-01-01", "2018-01-31")
syms = df["vt_symbol"].unique().to_list()
assert set(syms) == {"600000.SSE", "000001.SZSE"}
def test_load_lookback_buffer_and_vwap(db):
df = load_universe_bars(db, "2018-01-01", "2018-01-31")
# lookback 生效:2017-10 的 bar 也在(因子窗口预热需要)
assert str(df["datetime"].min()).startswith("2017-10")
# vwap = turnover/volume
row = df.filter(df["vt_symbol"] == "600000.SSE").sort("datetime").row(0, named=True)
assert row["vwap"] == pytest.approx(10.0)
def test_bar_idx_per_symbol(db):
df = load_universe_bars(db, "2018-01-01", "2018-01-31").sort(["vt_symbol", "datetime"])
idx = df.filter(df["vt_symbol"] == "000001.SZSE")["bar_idx"].to_list()
assert idx == [0, 1]
def test_evaluation_filter_warmup(db):
df = load_universe_bars(db, "2018-01-01", "2018-01-31")
ev = evaluation_filter(df, "2018-01-01", "2018-01-31")
# 000001 只有2根 < WARMUP_BARS → 全排除;600000 保留
assert set(ev["vt_symbol"].unique().to_list()) == {"600000.SSE"}
assert ev.height == 3
def test_explicit_symbols(db):
df = load_universe_bars(db, "2018-01-01", "2018-01-31", symbols=["000001"])
assert set(df["vt_symbol"].unique().to_list()) == {"000001.SZSE"}
def test_limit_deterministic(db):
df1 = load_universe_bars(db, "2018-01-01", "2018-01-31", limit=1)
df2 = load_universe_bars(db, "2018-01-01", "2018-01-31", limit=1)
assert set(df1["vt_symbol"].unique()) == set(df2["vt_symbol"].unique())
```
- [ ] **Step 2: 跑测试确认失败**
Run: `venv310/bin/python -m pytest tests/factor/test_universe.py -v`
Expected: FAILModuleNotFoundError
- [ ] **Step 3: 实现**
```python
# sanguo_factor/universe.py
"""评估股票池:dbbardata 分块直读 → AlphaLab 格式 polars DataFrame.
口径(spec):主板+创业板(前缀 60/00/30,剔科创68/北交/ETF),退市股保留,
120 交易日预热(次新 + 因子窗口)。不做 ST 过滤(无 point-in-time 名称史,
当前名回溯过滤=前视偏差,比不过滤更糟)。
"""
import random
import sqlite3
from datetime import datetime, timedelta
import polars as pl
STOCK_PREFIXES = ("60", "00", "30")
WARMUP_BARS = 120
_CHUNK = 300 # 分块 IN 查询每块 symbol 数(控瞬时内存)
_LOOKBACK_DAYS = 300 # start 前缓冲(覆盖最大 60 日窗口 + 节假日)
_FORWARD_DAYS = 45 # end 后缓冲(覆盖 10 日前瞻收益)
_COLS = "symbol, exchange, datetime, volume, turnover, open_interest, open_price, high_price, low_price, close_price"
def load_universe_bars(
vnpy_db: str,
start: str,
end: str,
symbols: list[str] | None = None,
limit: int | None = None,
) -> pl.DataFrame:
"""读评估窗(含前后缓冲)全A日线 → AlphaLab 格式(含 vwap/bar_idx)."""
lookback_start = (
datetime.strptime(start, "%Y-%m-%d") - timedelta(days=_LOOKBACK_DAYS)
).strftime("%Y-%m-%d")
forward_end = (
datetime.strptime(end, "%Y-%m-%d") + timedelta(days=_FORWARD_DAYS)
).strftime("%Y-%m-%d")
if symbols is None:
conn = sqlite3.connect(vnpy_db, timeout=30)
try:
likes = " OR ".join(f"symbol LIKE '{p}%'" for p in STOCK_PREFIXES)
cur = conn.execute(
f"SELECT DISTINCT symbol FROM dbbardata "
f"WHERE interval='d' AND datetime>=? AND datetime<=? AND ({likes})",
(lookback_start, forward_end),
)
symbols = [r[0] for r in cur.fetchall()]
finally:
conn.close()
if not symbols:
return _empty_alpha_df()
if limit is not None and limit < len(symbols):
symbols = sorted(random.Random(42).sample(symbols, limit))
chunks: list[pl.DataFrame] = []
for i in range(0, len(symbols), _CHUNK):
part = symbols[i : i + _CHUNK]
ph = ",".join("?" * len(part))
conn = sqlite3.connect(vnpy_db, timeout=30)
conn.execute("PRAGMA busy_timeout=30000")
try:
cur = conn.execute(
f"SELECT {_COLS} FROM dbbardata "
f"WHERE interval='d' AND datetime>=? AND datetime<=? AND symbol IN ({ph})",
(lookback_start, forward_end, *part),
)
rows = cur.fetchall()
finally:
conn.close()
if rows:
chunks.append(pl.DataFrame(
rows,
schema={"symbol": pl.Utf8, "exchange": pl.Utf8, "dt": pl.Utf8,
"volume": pl.Float64, "turnover": pl.Float64, "open_interest": pl.Float64,
"open": pl.Float64, "high": pl.Float64, "low": pl.Float64, "close": pl.Float64},
orient="row",
))
if not chunks:
return _empty_alpha_df()
df = pl.concat(chunks)
df = (
df.with_columns(
pl.col("dt").str.slice(0, 10).str.to_datetime("%Y-%m-%d").alias("datetime"),
(pl.col("symbol") + "." + pl.col("exchange")).alias("vt_symbol"),
pl.when(pl.col("volume") > 0)
.then(pl.col("turnover") / pl.col("volume"))
.otherwise(None)
.alias("vwap"),
)
.sort(["vt_symbol", "datetime"])
.with_columns(pl.int_range(pl.len()).over("vt_symbol").alias("bar_idx"))
.select(["vt_symbol", "datetime", "open", "high", "low", "close",
"volume", "turnover", "open_interest", "vwap", "bar_idx"])
)
return df
def evaluation_filter(df: pl.DataFrame, start: str, end: str) -> pl.DataFrame:
"""IC 评估行集:窗口内 + 预热期已过."""
return df.filter(
(pl.col("datetime") >= pl.lit(start).str.to_date())
& (pl.col("datetime") <= pl.lit(end).str.to_date())
& (pl.col("bar_idx") >= WARMUP_BARS)
)
def _empty_alpha_df() -> pl.DataFrame:
return pl.DataFrame(schema={
"vt_symbol": pl.Utf8, "datetime": pl.Datetime,
"open": pl.Float64, "high": pl.Float64, "low": pl.Float64, "close": pl.Float64,
"volume": pl.Float64, "turnover": pl.Float64, "open_interest": pl.Float64,
"vwap": pl.Float64, "bar_idx": pl.Int64,
})
```
> 实现注意:`pl.lit(start).str.to_date()` 与 `datetime` 列比较——若类型不匹配报错,改用 `datetime.strptime(start, "%Y-%m-%d")` python 对象做 `pl.lit(...)`。以测试通过为准,别死磕一种写法。
- [ ] **Step 4: 跑测试确认通过**
Run: `venv310/bin/python -m pytest tests/factor/test_universe.py -v`
Expected: 6 个全 PASS
- [ ] **Step 5: Commit**
```bash
git add sanguo_factor/universe.py tests/factor/test_universe.py
git commit -m "feat(factor): 评估股票池加载器——dbbardata分块直读polars,前缀60/00/30,300天lookback+45天forward缓冲,vwap派生,bar_idx预热 [vps]"
```
---
### Task 4: 向量化评估指标(metrics
**Files:**
- Create: `sanguo_factor/metrics.py`
- Test: `tests/factor/test_metrics.py`
**Interfaces:**
- Produces(全部操作 pandas DataFrameindex=日期 DatetimeIndexcolumns=vt_symbol,值=因子/收益;NaN=缺失自动剔除):
- `TRADING_DAYS_PER_YEAR = 244`
- `rank_corr_rows(A: pd.DataFrame, B: pd.DataFrame) -> pd.Series`:逐日 Spearman 秩相关(列自动取交集)
- `factor_turnover(F: pd.DataFrame) -> float``1 - 相邻两日因子秩相关` 均值
- `long_short_annual_return(F, R) -> float | None`top10%bottom10% 等权日收益均值复利年化
- `decile_annual_returns(F, R) -> list[float | None]`:十分组等权年化(D1 最低→D10 最高)
- `monthly_ic(ic: pd.Series) -> list[dict]``[{"month": "2018-01", "ic": 0.05}, ...]`
- `classify(icir, t_stat) -> str``"effective" | "watch" | "eliminated"`|ICIR|≥0.3 且 |t|≥2 → effective;否则 |t|≥1.5 → watch
- `summarize_factor(F, R1, R5, R10) -> dict``{"1": {...}, "5": {...}, "10": {...}, "turnover": float}`,每周期含 `count, ic_mean, ic_std, icir, t_stat, win_rate, ls_annual, monthly_ic, deciles, conclusion`;单因子异常时返回 `{"error": str}` 由调用方包装
- [ ] **Step 1: 写失败测试**
```python
# tests/factor/test_metrics.py
"""向量化指标:已知输入的精确断言."""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
import numpy as np
import pandas as pd
import pytest
from sanguo_factor.metrics import (
rank_corr_rows, factor_turnover, long_short_annual_return,
decile_annual_returns, monthly_ic, classify, summarize_factor,
TRADING_DAYS_PER_YEAR,
)
def _mat(values, cols=("A", "B", "C")):
idx = pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"])
return pd.DataFrame(values, index=idx, columns=list(cols), dtype=float)
def test_rank_corr_perfect_monotonic():
F = _mat([[1, 2, 3], [3, 2, 1], [1, 3, 2]])
R = _mat([[10, 20, 30], [30, 20, 10], [10, 30, 20]])
ic = rank_corr_rows(F, R)
assert (ic == 1.0).all()
def test_rank_corr_inverse():
F = _mat([[1, 2, 3], [1, 2, 3]])
R = _mat([[3, 2, 1], [30, 20, 10]])
ic = rank_corr_rows(F, R)
assert (ic == -1.0).all()
def test_rank_corr_nan_propagates_row():
F = _mat([[1, 2, 3], [np.nan, 2, 3]])
R = _mat([[1, 2, 3], [1, 2, 3]])
ic = rank_corr_rows(F, R)
assert not np.isnan(ic.iloc[0])
assert np.isnan(ic.iloc[1]) # 2个有效值秩恒定 → 无方差 → nan
def test_turnover_zero_for_static():
F = _mat([[1, 2, 3]] * 4)
assert factor_turnover(F) == pytest.approx(0.0)
def test_turnover_full_for_shuffled():
F = _mat([[1, 2, 3], [3, 2, 1]]) # 完全逆序 → 秩相关-1 → 换手=2
assert factor_turnover(F) == pytest.approx(2.0)
def test_long_short_direction():
F = _mat([[1, 2, 3, 4, 5], [5, 4, 3, 2, 1]], cols=tuple("ABCDE"))
R = _mat([[0.01, 0.02, 0.03, 0.04, 0.05],
[0.01, 0.02, 0.03, 0.04, 0.05]], cols=tuple("ABCDE"))
# 每日 top10%(1只)=E bottom10%=A → 日均 0.04
expected = (1 + 0.04) ** TRADING_DAYS_PER_YEAR - 1
assert long_short_annual_return(F, R) == pytest.approx(expected)
def test_decile_monotonic():
F = _mat([list(range(1, 11))] * 3, cols=tuple("ABCDEFGHIJ"))
R = _mat([[c / 100 for c in range(1, 11)]] * 3, cols=tuple("ABCDEFGHIJ"))
dec = decile_annual_returns(F, R)
assert len(dec) == 10
assert all(d is not None for d in dec)
assert dec == sorted(dec) # D1最低收益 → D10最高收益 单调
def test_monthly_ic_shape():
idx = pd.to_datetime(["2024-01-05", "2024-01-10", "2024-02-01"])
out = monthly_ic(pd.Series([0.1, 0.2, -0.1], index=idx))
assert out[0] == {"month": "2024-01", "ic": pytest.approx(0.15)}
assert out[1] == {"month": "2024-02", "ic": pytest.approx(-0.1)}
def test_classify_rules():
assert classify(0.5, 5.0) == "effective"
assert classify(-0.4, -3.0) == "effective" # 负 IC 强因子同样有效(反向)
assert classify(0.1, 1.8) == "watch"
assert classify(0.0, 0.5) == "eliminated"
def test_summarize_factor_structure():
rng = np.random.default_rng(7)
idx = pd.bdate_range("2024-01-01", periods=60)
base = np.tile(np.arange(10.0, 90.0, 1.0), (60, 1))
F = pd.DataFrame(base + rng.normal(0, 0.5, base.shape), index=idx)
R1 = pd.DataFrame(-0.001 * base + rng.normal(0, 0.001, base.shape), index=idx, columns=F.columns)
R5, R10 = R1 * 5, R1 * 10
out = summarize_factor(F, R1, R5, R10)
assert set(out) == {"1", "5", "10", "turnover"}
p1 = out["1"]
for key in ("count", "ic_mean", "ic_std", "icir", "t_stat", "win_rate",
"ls_annual", "monthly_ic", "deciles", "conclusion"):
assert key in p1
assert p1["count"] == 60
assert p1["ic_mean"] < 0 # 构造为负相关
```
- [ ] **Step 2: 跑测试确认失败**
Run: `venv310/bin/python -m pytest tests/factor/test_metrics.py -v`
Expected: FAILModuleNotFoundError
- [ ] **Step 3: 实现**
```python
# sanguo_factor/metrics.py
"""批量评估指标:全向量化(RankIC=秩相关逐行),不依赖 alphalens.
所有矩阵约定:pandas DataFrame,index=DatetimeIndex(日),columns=vt_symbol,
值=因子值或前瞻收益,NaN=缺失自动从当日截面剔除。
"""
import warnings
import numpy as np
import pandas as pd
TRADING_DAYS_PER_YEAR = 244
def _row_pearson(a: np.ndarray, b: np.ndarray, index: pd.Index) -> pd.Series:
"""逐行 Pearson(输入已是秩),全 NaN/无方差行 → NaN."""
mask = ~(np.isnan(a) | np.isnan(b))
n = mask.sum(axis=1)
a0 = np.where(mask, a, np.nan)
b0 = np.where(mask, b, np.nan)
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
am = np.nanmean(a0, axis=1, keepdims=True)
bm = np.nanmean(b0, axis=1, keepdims=True)
ad = np.where(mask, a0 - am, 0.0)
bd = np.where(mask, b0 - bm, 0.0)
denom = np.sqrt((ad ** 2).sum(axis=1) * (bd ** 2).sum(axis=1))
with np.errstate(invalid="ignore", divide="ignore"):
ic = np.where(denom > 0, (ad * bd).sum(axis=1) / denom, np.nan)
ic = np.where(n >= 3, ic, np.nan) # <3 只无意义
return pd.Series(ic, index=index)
def rank_corr_rows(A: pd.DataFrame, B: pd.DataFrame) -> pd.Series:
"""逐日 Spearman:先各自按行取秩再逐行 Pearson."""
cols = A.columns.intersection(B.columns)
idx = A.index.intersection(B.index)
a = A.loc[idx, cols].rank(axis=1).to_numpy(dtype=float)
b = B.loc[idx, cols].rank(axis=1).to_numpy(dtype=float)
return _row_pearson(a, b, idx)
def factor_turnover(F: pd.DataFrame) -> float:
"""换手率 = 1 - 相邻两日因子秩相关均值."""
if len(F) < 2:
return 0.0
corr = rank_corr_rows(F.iloc[1:], F.iloc[:-1]).replace([np.inf, -np.inf], np.nan).dropna()
return float(1.0 - corr.mean()) if len(corr) else 0.0
def _quantile_mask(F: pd.DataFrame, lo_frac: float, hi_frac: float) -> pd.DataFrame:
"""按行把因子值分位选mask(基于升序秩/当日有效数)."""
ranks = F.rank(axis=1, ascending=False) # 1=最大
n = ranks.notna().sum(axis=1)
k = np.maximum((n * 0.1).round().astype(int), 1)
if lo_frac == 0.0:
return ranks.le(k, axis=0) & ranks.notna()
return ranks.ge(n - k + 1, axis=0) & ranks.notna()
def long_short_annual_return(F: pd.DataFrame, R: pd.DataFrame) -> float | None:
"""多空年化:top10% - bottom10% 等权前瞻日收益均值,复利年化."""
cols = F.columns.intersection(R.columns)
idx = F.index.intersection(R.index)
f, r = F.loc[idx, cols], R.loc[idx, cols]
top = _quantile_mask(f, 0.0, 0.1)
bot = _quantile_mask(f, 0.9, 1.0)
daily = (r.where(top).mean(axis=1) - r.where(bot).mean(axis=1)).dropna()
if daily.empty:
return None
return float((1.0 + daily.mean()) ** TRADING_DAYS_PER_YEAR - 1.0)
def decile_annual_returns(F: pd.DataFrame, R: pd.DataFrame) -> list[float | None]:
"""十分组(D1因子最低→D10最高)等权年化收益."""
cols = F.columns.intersection(R.columns)
idx = F.index.intersection(R.index)
f, r = F.loc[idx, cols], R.loc[idx, cols]
pct = f.rank(axis=1, ascending=True).div(f.notna().sum(axis=1), axis=0)
out: list[float | None] = []
for d in range(10):
sel = (pct > d / 10) & (pct <= (d + 1) / 10)
daily = r.where(sel).mean(axis=1).dropna()
out.append(
float((1.0 + daily.mean()) ** TRADING_DAYS_PER_YEAR - 1.0)
if len(daily) else None
)
return out
def monthly_ic(ic: pd.Series) -> list[dict]:
"""IC 按月聚合(sparkline/详情图数据)."""
s = ic.dropna()
if s.empty:
return []
g = s.groupby(s.index.to_period("M").to_timestamp()).mean()
return [{"month": t.strftime("%Y-%m"), "ic": round(float(v), 6)} for t, v in g.items()]
def classify(icir: float | None, t_stat: float | None) -> str:
"""结论信号灯:|ICIR|>=0.3 且 |t|>=2 有效;否则 |t|>=1.5 观察;其余淘汰."""
if icir is None or t_stat is None:
return "eliminated"
if abs(icir) >= 0.3 and abs(t_stat) >= 2.0:
return "effective"
if abs(t_stat) >= 1.5:
return "watch"
return "eliminated"
def _period_stats(ic: pd.Series) -> dict:
s = ic.dropna()
n = len(s)
if n < 2:
return {"count": int(n), "ic_mean": None, "ic_std": None,
"icir": None, "t_stat": None, "win_rate": None,
"monthly_ic": monthly_ic(ic)}
m = float(s.mean())
sd = float(s.std())
icir = m / sd if sd > 0 else None
t_stat = m / (sd / n ** 0.5) if sd > 0 else None
return {
"count": int(n),
"ic_mean": m,
"ic_std": sd,
"icir": icir,
"t_stat": t_stat,
"win_rate": float((s > 0).mean()),
"monthly_ic": monthly_ic(ic),
}
def summarize_factor(F: pd.DataFrame, R1: pd.DataFrame, R5: pd.DataFrame, R10: pd.DataFrame) -> dict:
"""单因子全指标:三周期 IC 族 + 换手 + 多空 + 十分组 + 结论."""
out: dict = {"turnover": factor_turnover(F)}
for p, R in (("1", R1), ("5", R5), ("10", R10)):
ic = rank_corr_rows(F, R)
stats = _period_stats(ic)
stats["ls_annual"] = long_short_annual_return(F, R)
stats["deciles"] = decile_annual_returns(F, R)
stats["conclusion"] = classify(stats["icir"], stats["t_stat"])
out[p] = stats
return out
```
- [ ] **Step 4: 跑测试确认通过**
Run: `venv310/bin/python -m pytest tests/factor/test_metrics.py -v`
Expected: 全 PASS`test_rank_corr_nan_propagates_row` 那条:两列秩无方差 → denom=0 → NaN,与实现一致)
- [ ] **Step 5: Commit**
```bash
git add sanguo_factor/metrics.py tests/factor/test_metrics.py
git commit -m "feat(factor): 向量化评估指标——RankIC逐行秩相关/ICIR/t/胜率/多空年化/换手/十分组/月度IC/结论分级 [vps]"
```
---
### Task 5: 评估结果存储(eval_store
**Files:**
- Create: `sanguo_factor/eval_store.py`
- Test: `tests/factor/test_eval_store.py`
**Interfaces:**
- Produces:
- `init_db(path)`
- `create_run(path, label, universe, symbols_count, factors_total, start, end, params: dict) -> str`run_id 形如 `ev_20260824_1530_a1b2`
- `save_results(path, run_id, rows: list[dict])`rows 元素 = `{factor, category, expression, metrics: dict}`UPSERT
- `finish_run(path, run_id, status, factors_done)`
- `list_runs(path) -> list[dict]`created_at 倒序)
- `get_rows(path, run_id, category=None, search=None) -> list[dict]`metrics 已反序列化)
- `get_detail(path, run_id, factor) -> dict | None`
- `default_eval_db_path() -> str`orchestrator backtest_results.db 同目录下 `factor_eval.db`;环境变量 `SANGUO_FACTOR_EVAL_DB` 可覆盖(NAS/VPS/本地三环境同一套代码)
- [ ] **Step 1: 写失败测试**
```python
# tests/factor/test_eval_store.py
"""eval_store:建库/建run/UPSERT落盘/查询过滤/finish."""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from sanguo_factor import eval_store
def _rows():
return [
{"factor": "alpha2", "category": "alpha101", "expression": "(-1)*ts_corr(...)",
"metrics": {"1": {"ic_mean": 0.05}, "turnover": 0.3}},
{"factor": "kmid", "category": "alpha158", "expression": "(close-open)/open",
"metrics": {"1": {"ic_mean": -0.02}, "turnover": 0.6}},
]
def test_roundtrip(tmp_path):
db = str(tmp_path / "factor_eval.db")
eval_store.init_db(db)
run_id = eval_store.create_run(db, label="冒烟", universe="custom", symbols_count=50,
factors_total=2, start="2024-01-01", end="2024-12-31",
params={"periods": [1, 5, 10]})
eval_store.save_results(db, run_id, _rows())
eval_store.finish_run(db, run_id, "done", factors_done=2)
runs = eval_store.list_runs(db)
assert len(runs) == 1 and runs[0]["run_id"] == run_id and runs[0]["status"] == "done"
rows = eval_store.get_rows(db, run_id)
assert len(rows) == 2
assert rows[0]["metrics"]["1"]["ic_mean"] == 0.05
assert eval_store.get_detail(db, run_id, "kmid")["category"] == "alpha158"
assert eval_store.get_detail(db, run_id, "nope") is None
def test_filter_by_category_and_search(tmp_path):
db = str(tmp_path / "factor_eval.db")
eval_store.init_db(db)
run_id = eval_store.create_run(db, label="x", universe="all_a", symbols_count=1,
factors_total=2, start="", end="", params={})
eval_store.save_results(db, run_id, _rows())
assert len(eval_store.get_rows(db, run_id, category="alpha101")) == 1
assert len(eval_store.get_rows(db, run_id, search="corr")) == 1 # 搜表达式
assert len(eval_store.get_rows(db, run_id, search="kmid")) == 1 # 搜因子名
def test_upsert_same_run(tmp_path):
db = str(tmp_path / "factor_eval.db")
eval_store.init_db(db)
run_id = eval_store.create_run(db, label="x", universe="all_a", symbols_count=1,
factors_total=1, start="", end="", params={})
eval_store.save_results(db, run_id, [_rows()[0]])
eval_store.save_results(db, run_id, [{**_rows()[0], "metrics": {"1": {"ic_mean": 0.09}}}] * 1)
rows = eval_store.get_rows(db, run_id)
assert len(rows) == 1 and rows[0]["metrics"]["1"]["ic_mean"] == 0.09
```
- [ ] **Step 2: 跑测试确认失败**
Run: `venv310/bin/python -m pytest tests/factor/test_eval_store.py -v`
Expected: FAILModuleNotFoundError
- [ ] **Step 3: 实现**
```python
# sanguo_factor/eval_store.py
"""批量评估结果落盘 factor_eval.db(eval_runs + eval_results).
metrics 按周期 {"1": {...}, "5": {...}, "10": {...}, "turnover": float} 存 JSON,
schema 平坦、加周期零迁移。
"""
import json
import os
import sqlite3
import uuid
from datetime import datetime
_SCHEMA = """
CREATE TABLE IF NOT EXISTS eval_runs(
run_id TEXT PRIMARY KEY,
label TEXT NOT NULL,
universe TEXT NOT NULL,
symbols_count INTEGER NOT NULL,
factors_total INTEGER NOT NULL,
start TEXT NOT NULL,
end TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
finished_at TEXT,
factors_done INTEGER NOT NULL DEFAULT 0,
params_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS eval_results(
run_id TEXT NOT NULL,
factor TEXT NOT NULL,
category TEXT NOT NULL,
expression TEXT NOT NULL,
metrics_json TEXT NOT NULL,
PRIMARY KEY(run_id, factor)
);
"""
def default_eval_db_path() -> str:
"""SANGUO_FACTOR_EVAL_DB 覆盖;默认与 backtest_results.db 同目录."""
env = os.environ.get("SANGUO_FACTOR_EVAL_DB")
if env:
return env
from sanguo_data.config import load_config, find_config_path
cfg = load_config(find_config_path())
vnpy_db = cfg.data_paths.get("vnpy_db", "")
return os.path.join(os.path.dirname(os.path.abspath(vnpy_db)), "factor_eval.db")
def _conn(path: str) -> sqlite3.Connection:
conn = sqlite3.connect(path, timeout=30)
conn.execute("PRAGMA busy_timeout=30000")
return conn
def init_db(path: str) -> None:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
conn = _conn(path)
try:
conn.executescript(_SCHEMA)
conn.commit()
finally:
conn.close()
def create_run(path, label, universe, symbols_count, factors_total, start, end, params) -> str:
run_id = f"ev_{datetime.now():%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:4]}"
conn = _conn(path)
try:
conn.execute(
"INSERT INTO eval_runs VALUES(?,?,?,?,?,?,?,?,?,?,0,?)",
(run_id, label, universe, symbols_count, factors_total, start, end,
"running", datetime.now().isoformat(timespec='seconds'), None,
json.dumps(params, ensure_ascii=False)),
)
conn.commit()
finally:
conn.close()
return run_id
def save_results(path, run_id, rows: list[dict]) -> None:
conn = _conn(path)
try:
conn.executemany(
"INSERT OR REPLACE INTO eval_results VALUES(?,?,?,?,?)",
[(run_id, r["factor"], r["category"], r["expression"],
json.dumps(r["metrics"], ensure_ascii=False)) for r in rows],
)
cur = conn.execute(
"SELECT COUNT(*) FROM eval_results WHERE run_id=?", (run_id,))
done = cur.fetchone()[0]
conn.execute("UPDATE eval_runs SET factors_done=? WHERE run_id=?", (done, run_id))
conn.commit()
finally:
conn.close()
def finish_run(path, run_id, status, factors_done) -> None:
conn = _conn(path)
try:
conn.execute(
"UPDATE eval_runs SET status=?, finished_at=?, factors_done=? WHERE run_id=?",
(status, datetime.now().isoformat(timespec='seconds'), factors_done, run_id),
)
conn.commit()
finally:
conn.close()
def list_runs(path) -> list[dict]:
conn = _conn(path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT * FROM eval_runs ORDER BY created_at DESC").fetchall()
finally:
conn.close()
return [dict(r) for r in rows]
def get_rows(path, run_id, category=None, search=None) -> list[dict]:
q = "SELECT * FROM eval_results WHERE run_id=?"
args: list = [run_id]
if category:
q += " AND category=?"
args.append(category)
if search:
q += " AND (factor LIKE ? OR expression LIKE ?)"
args.extend([f"%{search}%", f"%{search}%"])
conn = _conn(path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(q, args).fetchall()
finally:
conn.close()
return [{**dict(r), "metrics": json.loads(r["metrics_json"])} for r in rows]
def get_detail(path, run_id, factor) -> dict | None:
conn = _conn(path)
conn.row_factory = sqlite3.Row
try:
r = conn.execute(
"SELECT * FROM eval_results WHERE run_id=? AND factor=?",
(run_id, factor)).fetchone()
finally:
conn.close()
if r is None:
return None
return {**dict(r), "metrics": json.loads(r["metrics_json"])}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `venv310/bin/python -m pytest tests/factor/test_eval_store.py -v`
Expected: 全 PASS
- [ ] **Step 5: Commit**
```bash
git add sanguo_factor/eval_store.py tests/factor/test_eval_store.py
git commit -m "feat(factor): 评估结果存储 factor_eval.db——runs/results两表,metrics按周期JSON,SANGUO_FACTOR_EVAL_DB可覆盖 [vps]"
```
---
### Task 6: 批量评估引擎(batch_eval
**Files:**
- Create: `sanguo_factor/batch_eval.py`
- Test: `tests/factor/test_batch_eval.py`
**Interfaces:**
- Consumes: Task 2 `mount_all()`/`get_factor`、Task 3 `load_universe_bars/evaluation_filter`、Task 4 `summarize_factor`、Task 5 store API、vnpy `calculate_by_expression`
- Produces:
- `run_batch_eval(factor_names: list[str], start: str, end: str, eval_db: str, label: str, universe: str = "all_a", symbols: list[str] | None = None, limit: int | None = None, cfg=None, progress_cb=None) -> dict`
- 返回 `{"run_id", "factors_total", "factors_done", "errors", "elapsed_sec"}`
- `progress_cb(done: int, total: int, current: str)` 每因子一次(CLI 打印用;API 侧暂不接,YAGNI)
- 单因子计算异常**不中断**:该因子落一行 `metrics={"error": "..."}` 继续
- [ ] **Step 1: 写失败测试**
```python
# tests/factor/test_batch_eval.py
"""批量引擎端到端:合成小库 → 2因子计算 → 指标落盘可查."""
import sqlite3
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0")))
import pytest
from sanguo_factor import alpha_datasets # 挂载
from sanguo_factor.batch_eval import run_batch_eval
from sanguo_factor import eval_store
_DDL = """
CREATE TABLE dbbardata(
symbol TEXT, exchange TEXT, datetime TEXT, interval TEXT,
volume REAL, turnover REAL, open_interest REAL,
open_price REAL, high_price REAL, low_price REAL, close_price REAL)
"""
@pytest.fixture(scope="module")
def db(tmp_path_factory):
"""3 只 × ~400 交易日(2017-06~2018-12)合成库:一只单调涨/一只震荡/一只反着走."""
p = tmp_path_factory.mktemp("b")
db = str(p / "qt.db")
conn = sqlite3.connect(db)
conn.execute(_DDL)
import pandas as pd
days = pd.bdate_range("2017-06-01", "2018-12-28")
for i, day in enumerate(days):
d = day.strftime("%Y-%m-%d")
rows = [
("600000", "SSE", f"{d} 00:00:00", "d", 100.0, 1_000_000.0, 0,
10 + i * 0.01, 10 + i * 0.01, 10 + i * 0.01, 10 + i * 0.01), # 单调涨
("000001", "SZSE", f"{d} 00:00:00", "d", 100.0, 500_000.0, 0,
5, 5, 5, 5), # 平盘(截面另一端)
("300001", "SZSE", f"{d} 00:00:00", "d", 200.0, 900_000.0, 0,
20 - i * 0.005, 20 - i * 0.005, 20 - i * 0.005, 20 - i * 0.005), # 缓跌
]
conn.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows)
conn.commit(); conn.close()
return db
def test_run_batch_eval_end_to_end(db, tmp_path, monkeypatch):
eval_db = str(tmp_path / "factor_eval.db")
out = run_batch_eval(
factor_names=["ma_20", "roc_5"], start="2018-01-01", end="2018-06-30",
eval_db=eval_db, label="t", symbols=None, cfg=None, vnpy_db_override=db,
)
assert out["factors_done"] == 2
assert out["errors"] == []
rows = eval_store.get_rows(eval_db, out["run_id"])
assert {r["factor"] for r in rows} == {"ma_20", "roc_5"}
# ma_20 = ts_mean(close,20)/close:涨股该值持续低(均价低于现价) 跌股高 → 与次日收益负相关为主
m = eval_store.get_detail(eval_db, out["run_id"], "ma_20")["metrics"]
assert "1" in m and "5" in m and "10" in m and "turnover" in m
assert isinstance(m["1"]["ic_mean"], float)
def test_bad_factor_recorded_not_fatal(db, tmp_path):
eval_db = str(tmp_path / "factor_eval.db")
out = run_batch_eval(
factor_names=["不存在的因子", "kmid"], start="2018-01-01", end="2018-03-31",
eval_db=eval_db, label="t2", cfg=None, vnpy_db_override=db,
)
assert out["factors_done"] == 2
assert len(out["errors"]) == 1
detail = eval_store.get_detail(eval_db, out["run_id"], "不存在的因子")
assert "error" in detail["metrics"]
```
> 注:测试用的 `vnpy_db_override` 参数是**测试注入数据库路径**的后门(生产 cfg 走 `cfg.data_paths["vnpy_db"]`);实现签名里带上它,默认 None。
- [ ] **Step 2: 跑测试确认失败**
Run: `venv310/bin/python -m pytest tests/factor/test_batch_eval.py -v`
Expected: FAILModuleNotFoundError
- [ ] **Step 3: 实现**
```python
# sanguo_factor/batch_eval.py
"""批量评估引擎:全A bars → 进程内逐因子 calculate_by_expression → 指标 → 落盘.
不走 AlphaDataset.prepare_data(其 spawn 池对每个表达式 pickle 整个 DataFrame,
9M 行 × 258 因子的传输开销不可接受);calculate_by_expression 纯进程内 polars,
内存随单因子天然有界。现有单因子分析链路(analyzer/alphalens tears)零改动。
"""
import sys
import os
import time
_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 pandas as pd
import polars as pl
from .universe import load_universe_bars, evaluation_filter
from .registry import get_factor
from . import eval_store
from .metrics import summarize_factor
def _forward_return_matrices(close_wide: pd.DataFrame, periods=(1, 5, 10)) -> dict[int, pd.DataFrame]:
out = {}
for p in periods:
out[p] = close_wide.shift(-p) / close_wide - 1.0
return out
def run_batch_eval(
factor_names: list[str],
start: str,
end: str,
eval_db: str,
label: str,
universe: str = "all_a",
symbols: list[str] | None = None,
limit: int | None = None,
cfg=None,
progress_cb=None,
vnpy_db_override: str | None = None,
) -> dict:
"""跑一轮批量评估,结果增量写入 eval_db,返回摘要."""
from vnpy.alpha.dataset.utility import calculate_by_expression
if cfg is None:
from sanguo_data.config import load_config, find_config_path
cfg = load_config(find_config_path())
vnpy_db = vnpy_db_override or cfg.data_paths["vnpy_db"]
t0 = time.time()
bars = load_universe_bars(vnpy_db, start, end, symbols=symbols, limit=limit)
if bars.height == 0:
raise ValueError(f"股票池为空: vnpy_db={vnpy_db} window={start}~{end}")
alpha_df = bars.select(["vt_symbol", "datetime", "open", "high", "low", "close",
"volume", "turnover", "open_interest", "vwap"])
eval_rows = evaluation_filter(bars, start, end).select(["vt_symbol", "datetime"])
close_wide = (
bars.select(["datetime", "vt_symbol", "close"])
.pivot(index="datetime", on="vt_symbol", values="close")
.sort("datetime").to_pandas().set_index("datetime")
)
close_wide.index = pd.to_datetime(close_wide.index)
rets = _forward_return_matrices(close_wide)
universe_label = universe if symbols is None else "custom"
eval_store.init_db(eval_db)
run_id = eval_store.create_run(
eval_db, label=label, universe=universe_label,
symbols_count=bars["vt_symbol"].n_unique(), factors_total=len(factor_names),
start=start, end=end, params={"limit": limit, "symbols": symbols[:20] if symbols else None},
)
errors: list[str] = []
done = 0
buffer: list[dict] = []
for i, name in enumerate(factor_names):
row = _eval_one(name, alpha_df, eval_rows, rets)
if "error" in row["metrics"]:
errors.append(name)
buffer.append(row)
done += 1
if len(buffer) >= 20 or done == len(factor_names):
eval_store.save_results(eval_db, run_id, buffer)
buffer = []
if progress_cb:
progress_cb(done, len(factor_names), name)
eval_store.finish_run(eval_db, run_id, "done", factors_done=done)
return {
"run_id": run_id,
"factors_total": len(factor_names),
"factors_done": done,
"errors": errors,
"elapsed_sec": round(time.time() - t0, 1),
"symbols_count": int(bars["vt_symbol"].n_unique()),
}
def _eval_one(name: str, alpha_df: pl.DataFrame, eval_rows: pl.DataFrame,
rets: dict[int, pd.DataFrame]) -> dict:
factor = get_factor(name)
if factor is None:
return {"factor": name, "category": "unknown", "expression": "",
"metrics": {"error": f"因子未注册: {name}"}}
try:
res = calculate_by_expression(alpha_df, factor["expression"])
f_long = res.join(eval_rows, on=["vt_symbol", "datetime"], how="inner")
F = (
f_long.pivot(index="datetime", on="vt_symbol", values="data")
.sort("datetime").to_pandas().set_index("datetime")
)
F.index = pd.to_datetime(F.index)
metrics = summarize_factor(F, rets[1], rets[5], rets[10])
return {"factor": name, "category": factor["category"],
"expression": factor["expression"], "metrics": metrics}
except Exception as e: # 单因子失败不拖垮整批
return {"factor": name, "category": factor["category"],
"expression": factor["expression"],
"metrics": {"error": f"{type(e).__name__}: {e}"}}
```
> 实现注意:`res` 是 `calculate_by_expression` 返回的 `datetime/vt_symbol/data` 三列 dfjoin `eval_rows`(已含窗口+预热过滤)即完成行集裁剪。若 polars pivot 参数名对不上(`on=`/`columns=`),以 `venv310/bin/python -c "import polars as pl; help(pl.DataFrame.pivot)"` 查 1.42 签名为准。
- [ ] **Step 4: 跑测试确认通过**
Run: `venv310/bin/python -m pytest tests/factor/test_batch_eval.py -v`
Expected: 2 个全 PASS(首跑会触发 vnpy alpha import,几秒)
- [ ] **Step 5: Commit**
```bash
git add sanguo_factor/batch_eval.py tests/factor/test_batch_eval.py
git commit -m "feat(factor): 批量评估引擎——进程内逐因子calculate_by_expression(弃spawn池整df pickle),单因子失败不中断,结果增量落盘 [vps]"
```
---
### Task 7: CLI 脚本(scripts/factor_research/run_eval.py
**Files:**
- Create: `scripts/factor_research/run_eval.py`(目录新建,无 `__init__.py`,脚本以 `python scripts/factor_research/run_eval.py` 运行,顶部 sys.path 补 repo root——参考 scripts/diag_factor.py 的既有做法,先读它)
**Interfaces:**
- Produces: CLI,参数 `--start --end --categories alpha101 alpha158 --factors a,b --symbols 600000,000001 --limit 50 --label --db --write-every --list-categories --list-factors [category]`
- 默认:`--start 2018-01-01 --end 2026-06-30 --categories alpha101 alpha158 --label batch1`
- 结束打印 Top 15(按 1D |icir| 降序)小表格 + run_id
- [ ] **Step 1: 实现(CLI 薄壳,核心逻辑全在 Task 6,无需单测——用 --help + --list-factors 手验)**
```python
#!/usr/bin/env python
"""批量因子评估 CLI(冒烟/全量;NAS docker exec / 本地直跑).
用法示例:
冒烟(50只×2024×10因子):
venv310/bin/python scripts/factor_research/run_eval.py \
--start 2024-01-01 --end 2024-12-31 --limit 50 --label smoke-2024 \
--factors alpha2,alpha6,alpha12,alpha18,kmid,klen,roc_5,ma_20,std_20,wvma_20
全量(Alpha101+158 × 全A × 8.5年):
venv310/bin/python scripts/factor_research/run_eval.py --label batch1-full
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0")))
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--start", default="2018-01-01")
ap.add_argument("--end", default="2026-06-30")
ap.add_argument("--categories", nargs="*", default=["alpha101", "alpha158"])
ap.add_argument("--factors", default="", help="逗号分隔,优先于 --categories")
ap.add_argument("--symbols", default="", help="逗号分隔裸代码;空=全A")
ap.add_argument("--limit", type=int, default=None, help="随机抽样 N 只(种子42)")
ap.add_argument("--label", default="batch1")
ap.add_argument("--db", default=None, help="factor_eval.db 路径;默认 default_eval_db_path()")
ap.add_argument("--list-factors", default="", metavar="CATEGORY", help="列出类目因子后退出")
args = ap.parse_args()
from sanguo_factor.alpha_datasets import mount_all
mount_all()
from sanguo_factor.registry import list_factors
from sanguo_factor.batch_eval import run_batch_eval
from sanguo_factor.eval_store import default_eval_db_path, get_rows
if args.list_factors:
for f in list_factors(args.list_factors):
print(f"{f['name']:16s} [{f['category']}] {f['expression'][:80]}")
return 0
if args.factors:
factor_names = [s.strip() for s in args.factors.split(",") if s.strip()]
else:
factor_names = [f["name"] for c in args.categories for f in list_factors(c)]
if not factor_names:
print("未找到因子(检查 --categories/--factors)", file=sys.stderr)
return 1
db_path = args.db or default_eval_db_path()
symbols = [s.strip() for s in args.symbols.split(",") if s.strip()] or None
print(f"[eval] {len(factor_names)} 因子 × {args.start}~{args.end}{db_path}")
def _cb(done: int, total: int, current: str) -> None:
print(f"[eval] {done}/{total} {current}", flush=True)
out = run_batch_eval(factor_names, args.start, args.end, db_path, label=args.label,
symbols=symbols, limit=args.limit, cfg=None, progress_cb=_cb)
print(f"[eval] run_id={out['run_id']} done={out['factors_done']} "
f"errors={len(out['errors'])} elapsed={out['elapsed_sec']}s symbols={out['symbols_count']}")
if out["errors"]:
print(f"[eval] 失败因子: {', '.join(out['errors'][:20])}")
rows = get_rows(db_path, out["run_id"])
scored = []
for r in rows:
p1 = (r.get("metrics") or {}).get("1") or {}
if p1.get("icir") is not None:
scored.append((abs(p1["icir"]), r["factor"], p1))
scored.sort(reverse=True)
print(f"\n{'因子':<14s} {'IC':>8s} {'ICIR':>8s} {'t':>8s} {'胜率':>7s} 结论")
for _, name, p1 in scored[:15]:
print(f"{name:<14s} {p1['ic_mean']:>8.4f} {p1['icir']:>8.3f} "
f"{p1['t_stat']:>8.2f} {p1['win_rate']:>7.1%} {p1['conclusion']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
```
- [ ] **Step 2: 手验**
Run: `venv310/bin/python scripts/factor_research/run_eval.py --list-factors alpha101 | head -5 && venv310/bin/python scripts/factor_research/run_eval.py --help`
Expected: 列出 alpha1..alpha5--help 正常
- [ ] **Step 3: Commit**
```bash
git add scripts/factor_research/run_eval.py
git commit -m "feat(factor): 批量评估CLI——冒烟/全量/列因子,进度打印+Top15汇总表(NAS docker exec 冒烟入口) [vps]"
```
---
### Task 8: API 端点 + orchestrator 接线
**Files:**
- Modify: `sanguo_api/schemas.py`(追加 FactorBatchEvalRequest
- Create: `sanguo_api/routes_factor.py`
- Modify: `sanguo_api/app.py`(注册路由 + set_eval_db_path 接线)
- Modify: `sanguo_orchestrator/runner.py`(追加 submit_batch_eval/_batch_eval_worker/_persist_factor_eval
- Test: `tests/api/test_factor_eval_routes.py`
**Interfaces:**
- Consumes: Task 5 `eval_store`、Task 6 `run_batch_eval`、Task 2 挂载
- Produces:
- `GET /factor/eval/runs``{"runs": [...]}`
- `GET /factor/eval/leaderboard?run_id=&period=1|5|10&category=&search=&sort=abs_icir|ic_mean|t_stat|ls_annual&order=desc``{"tiles": {"factors_total","effective","watch","top_ls_annual","top_ls_factor"}, "rows": [...]}`row = 扁平化所选周期:`factor,category,expression,ic_mean,icir,t_stat,win_rate,ls_annual,turnover,conclusion,monthly_ic`run_id 缺省 = 最新 run
- `GET /factor/eval/detail?run_id=&factor=``{"factor","category","expression","metrics"}`(全周期)
- `POST /factor/eval/submit` body `{"categories":["alpha101"],"factors":[],"symbols":[],"start":"2018-01-01","end":"2026-06-30","label":"batch1"}``{"task_id": "factor_eval_xxxx"}`
- Orchestrator: `async submit_batch_eval(factor_names, start, end, symbols, label) -> task_id`;完成时 `_persist_factor_eval` 落 backtest_results.dbtype="factor_eval", statistics={"run_id", "factors_done", "errors"})使任务中心可见
- `set_eval_db_path(path)` 由 app.py 在启动时调(路径 = dirname(orchestrator db_path)/factor_eval.db
- [ ] **Step 1: 写失败测试**
```python
# tests/api/test_factor_eval_routes.py
"""/factor/eval/* 端点:leaderboard 扁平化/tiles/排序/详情/提交校验."""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sanguo_factor import eval_store
from sanguo_api import routes_factor
@pytest.fixture()
def client(tmp_path, monkeypatch):
db = str(tmp_path / "factor_eval.db")
eval_store.init_db(db)
routes_factor.set_eval_db_path(db)
app = FastAPI()
app.include_router(routes_factor.router)
return TestClient(app)
def _seed(db):
run_id = eval_store.create_run(db, label="冒烟", universe="custom", symbols_count=50,
factors_total=3, start="2024-01-01", end="2024-12-31", params={})
eval_store.save_results(db, run_id, [
{"factor": "alpha2", "category": "alpha101", "expression": "e2",
"metrics": {"turnover": 0.2, "1": {"ic_mean": 0.05, "icir": 0.4, "t_stat": 3.0,
"win_rate": 0.6, "ls_annual": 0.12, "conclusion": "effective",
"monthly_ic": [{"month": "2024-01", "ic": 0.05}]},
"5": {}, "10": {}}},
{"factor": "kmid", "category": "alpha158", "expression": "ek",
"metrics": {"turnover": 0.5, "1": {"ic_mean": -0.01, "icir": -0.05, "t_stat": -0.4,
"win_rate": 0.45, "ls_annual": -0.02, "conclusion": "eliminated",
"monthly_ic": []}, "5": {}, "10": {}}},
{"factor": "alpha6", "category": "alpha101", "expression": "e6",
"metrics": {"turnover": 0.3, "1": {"ic_mean": 0.03, "icir": 0.2, "t_stat": 1.7,
"win_rate": 0.55, "ls_annual": 0.06, "conclusion": "watch",
"monthly_ic": []}, "5": {}, "10": {}}},
])
eval_store.finish_run(db, run_id, "done", 3)
return run_id
def test_leaderboard_default_latest_run(client):
_seed(routes_factor._eval_db_path)
r = client.get("/factor/eval/leaderboard")
assert r.status_code == 200
body = r.json()
assert body["tiles"]["factors_total"] == 3
assert body["tiles"]["effective"] == 1
assert body["tiles"]["watch"] == 1
assert body["tiles"]["top_ls_factor"] == "alpha2"
# 默认 abs_icir desc: alpha2(0.4) > alpha6(0.2) > kmid(0.05)
assert [x["factor"] for x in body["rows"]] == ["alpha2", "alpha6", "kmid"]
row = body["rows"][0]
for key in ("factor", "category", "expression", "ic_mean", "icir", "t_stat",
"win_rate", "ls_annual", "turnover", "conclusion", "monthly_ic"):
assert key in row
def test_leaderboard_filter_and_period(client):
_seed(routes_factor._eval_db_path)
r = client.get("/factor/eval/leaderboard", params={"category": "alpha158"})
assert [x["factor"] for x in r.json()["rows"]] == ["kmid"]
r = client.get("/factor/eval/leaderboard", params={"search": "alpha6"})
assert [x["factor"] for x in r.json()["rows"]] == ["alpha6"]
def test_detail(client):
_seed(routes_factor._eval_db_path)
r = client.get("/factor/eval/detail", params={"factor": "kmid"})
assert r.status_code == 200
assert r.json()["category"] == "alpha158"
assert "1" in r.json()["metrics"]
assert client.get("/factor/eval/detail", params={"factor": "nope"}).status_code == 404
def test_runs_list(client):
_seed(routes_factor._eval_db_path)
r = client.get("/factor/eval/runs")
assert len(r.json()["runs"]) == 1
def test_submit_validation_error(client):
# categories 与 factors 双空 → 422/400
r = client.post("/factor/eval/submit",
json={"start": "2018-01-01", "end": "2026-06-30"})
assert r.status_code in (400, 422)
```
- [ ] **Step 2: 跑测试确认失败**
Run: `venv310/bin/python -m pytest tests/api/test_factor_eval_routes.py -v`
Expected: FAILno module routes_factor
- [ ] **Step 3: 实现**
`sanguo_api/schemas.py` 末尾追加:
```python
class FactorBatchEvalRequest(BaseModel):
"""Factor batch evaluation request"""
categories: list[str] = []
factors: list[str] = []
symbols: list[str] = []
start: str
end: str
label: str = ""
```
`sanguo_api/routes_factor.py`
```python
"""因子批量评估 API:runs/leaderboard/detail/submit."""
from fastapi import APIRouter, HTTPException, Depends, Query
from .schemas import FactorBatchEvalRequest
from .auth import verify_token as verify_token_impl
router = APIRouter(dependencies=[Depends(verify_token_impl)])
_eval_db_path: str | None = None
_SORT_KEYS = {
"abs_icir": lambda p: abs(p.get("icir") or 0.0),
"ic_mean": lambda p: p.get("ic_mean") if p.get("ic_mean") is not None else float("-inf"),
"t_stat": lambda p: abs(p.get("t_stat") or 0.0),
"ls_annual": lambda p: p.get("ls_annual") if p.get("ls_annual") is not None else float("-inf"),
}
def set_eval_db_path(path: str) -> None:
global _eval_db_path
_eval_db_path = path
def _db() -> str:
from sanguo_factor.eval_store import default_eval_db_path
return _eval_db_path or default_eval_db_path()
def _latest_run_id(db: str) -> str | None:
from sanguo_factor.eval_store import list_runs
runs = list_runs(db)
return runs[0]["run_id"] if runs else None
@router.get("/factor/eval/runs")
def eval_runs():
from sanguo_factor.eval_store import list_runs
return {"runs": list_runs(_db())}
@router.get("/factor/eval/leaderboard")
def eval_leaderboard(
run_id: str | None = None,
period: str = Query("1", pattern="^(1|5|10)$"),
category: str | None = None,
search: str | None = None,
sort: str = Query("abs_icir", pattern="^(abs_icir|ic_mean|t_stat|ls_annual)$"),
order: str = Query("desc", pattern="^(asc|desc)$"),
):
from sanguo_factor.eval_store import get_rows
db = _db()
rid = run_id or _latest_run_id(db)
if not rid:
return {"tiles": {"factors_total": 0, "effective": 0, "watch": 0,
"top_ls_annual": None, "top_ls_factor": None}, "rows": []}
rows = get_rows(db, rid, category=category, search=search)
flat: list[dict] = []
for r in rows:
m = r.get("metrics") or {}
p = m.get(period) or {}
if "error" in m and not p:
flat.append({"factor": r["factor"], "category": r["category"],
"expression": r["expression"], "error": m["error"],
"ic_mean": None, "icir": None, "t_stat": None, "win_rate": None,
"ls_annual": None, "turnover": m.get("turnover"),
"conclusion": "eliminated", "monthly_ic": []})
continue
flat.append({
"factor": r["factor"], "category": r["category"], "expression": r["expression"],
"ic_mean": p.get("ic_mean"), "icir": p.get("icir"), "t_stat": p.get("t_stat"),
"win_rate": p.get("win_rate"), "ls_annual": p.get("ls_annual"),
"turnover": m.get("turnover"), "conclusion": p.get("conclusion", "eliminated"),
"monthly_ic": p.get("monthly_ic", []),
})
keyfn = _SORT_KEYS[sort]
flat.sort(key=lambda x: (keyfn(x) is not None, keyfn(x)), reverse=(order == "desc"))
for i, row in enumerate(flat, 1):
row["rank"] = i
scored = [x for x in flat if x.get("ic_mean") is not None]
top_ls = max(scored, key=lambda x: (x.get("ls_annual") or float("-inf")), default=None)
tiles = {
"factors_total": len(flat),
"effective": sum(1 for x in flat if x["conclusion"] == "effective"),
"watch": sum(1 for x in flat if x["conclusion"] == "watch"),
"top_ls_annual": top_ls["ls_annual"] if top_ls else None,
"top_ls_factor": top_ls["factor"] if top_ls else None,
}
return {"tiles": tiles, "rows": flat}
@router.get("/factor/eval/detail")
def eval_detail(run_id: str | None = None, factor: str = ""):
from sanguo_factor.eval_store import get_detail
db = _db()
rid = run_id or _latest_run_id(db)
d = get_detail(db, rid, factor) if rid else None
if d is None:
raise HTTPException(status_code=404, detail="factor not found in run")
return d
@router.post("/factor/eval/submit")
async def eval_submit(req: FactorBatchEvalRequest):
if not req.categories and not req.factors:
raise HTTPException(status_code=400, detail="categories 与 factors 至少给一个")
from .routes import get_orchestrator
orch = get_orchestrator()
if orch is None:
raise HTTPException(status_code=503, detail="orchestrator 未就绪")
task_id = await orch.submit_batch_eval(
factor_names=req.factors, categories=req.categories,
start=req.start, end=req.end, symbols=req.symbols or None, label=req.label,
)
return {"task_id": task_id}
```
`sanguo_orchestrator/runner.py`(追加;`_batch_eval_worker` 必须**模块级**——spawn 进程池按引用 pickle):
```python
def _batch_eval_worker(factor_names: list, categories: list, start: str, end: str,
symbols, label: str, eval_db: str) -> dict:
"""进程池 worker:批量评估(spawn 环境,自行 load config/挂载因子)."""
from sanguo_factor.alpha_datasets import mount_all
mount_all()
if not factor_names:
from sanguo_factor.registry import list_factors
factor_names = [f["name"] for c in categories for f in list_factors(c)]
from sanguo_factor.batch_eval import run_batch_eval
return run_batch_eval(factor_names, start, end, eval_db, label=label, symbols=symbols)
```
`Orchestrator` 类内追加(仿 submit_factor):
```python
async def submit_batch_eval(self, factor_names: list, categories: list,
start: str, end: str, symbols, label: str) -> str:
"""Submit a factor batch evaluation task asynchronously"""
from .eval_db_path import resolve_eval_db # 或直接内联(见下)
task_id = f"factor_eval_{uuid.uuid4().hex[:8]}"
self._record_submit(task_id)
self.pool.submit(task_id, "factor_eval")
eval_db = resolve_eval_db(self.db_path)
fut: Future = self.pool.submit_work(
task_id, _batch_eval_worker, factor_names, categories,
start, end, symbols, label or "batch", eval_db,
)
task = self.pool.get_task(task_id)
task.start()
await self._notify_stage(task_id, "批量评估中")
asyncio.ensure_future(self._wait_future(task_id, fut))
return task_id
```
`resolve_eval_db` 别单独建文件——放 runner.py 模块级函数:
```python
def resolve_eval_db(backtest_db_path: str) -> str:
"""factor_eval.db 与 backtest_results.db 同目录(SANGUO_FACTOR_EVAL_DB 可覆盖)."""
import os
env = os.environ.get("SANGUO_FACTOR_EVAL_DB")
if env:
return env
return os.path.join(os.path.dirname(os.path.abspath(backtest_db_path or ".")), "factor_eval.db")
```
完成回写(在 `_wait_future` 完成分支里既有 `if getattr(result, "ic_summary", ...)` 之后并列加):
```python
# 批量评估结果摘要落任务中心(完整数据在 factor_eval.db)
if isinstance(result, dict) and "run_id" in result:
self._persist_factor_eval(task_id, result)
```
```python
def _persist_factor_eval(self, task_id: str, summary: dict) -> None:
"""批量评估摘要落 backtest_results.db(type=factor_eval)进任务列表."""
from sanguo_backtest.result_store import save_result, BacktestResult
try:
save_result(BacktestResult(
task_id=task_id, type="factor_eval", status="done",
strategy=summary.get("label", "batch"),
symbol=f"{summary.get('symbols_count', 0)}",
params={},
start="", end="",
statistics={"run_id": summary.get("run_id"),
"factors_done": summary.get("factors_done"),
"errors": summary.get("errors", [])[:20]},
equity_curve=None, trades=None,
), db_path=self.db_path)
except Exception as e:
import logging
logging.getLogger(__name__).warning("persist factor_eval %s failed: %s", task_id, e)
```
> ⚠️ 先读 `_wait_future` 实际代码再插入——上面片段是意图,插入点以「worker 返回 dict 且带 run_id」为判据,别破坏既有 FactorReport/BacktestResult 分支。同样先读 `BacktestResult` dataclass 字段(sanguo_backtest/result_store.py)确认字段名一致。
`sanguo_api/app.py`import 区加 `from .routes_factor import router as factor_eval_router, set_eval_db_path as set_factor_eval_db_path``include_router` 区加一行;创建 app/orchestrator 初始化处(找到 orchestrator db_path 设定的地方)加:
```python
from sanguo_orchestrator.runner import resolve_eval_db
set_factor_eval_db_path(resolve_eval_db(orchestrator_db_path))
```
> 先读 app.py 全文找现有 wiring 位置(routes_paper 的 set_db_path 怎么接就怎么接)。
- [ ] **Step 4: 跑测试确认通过 + 回归**
Run: `venv310/bin/python -m pytest tests/api/test_factor_eval_routes.py tests/api/test_factor_routes.py tests/factor -v`
Expected: 全 PASS(既有 /factor/list、/factor/analyze 测试不回归)
- [ ] **Step 5: Commit**
```bash
git add sanguo_api/schemas.py sanguo_api/routes_factor.py sanguo_api/app.py sanguo_orchestrator/runner.py tests/api/test_factor_eval_routes.py
git commit -m "feat(api): 因子批量评估四端点(runs/leaderboard/detail/submit)+orchestrator任务接线——leaderboard服务端扁平化排序,worker落factor_eval.db+任务中心摘要 [vps]"
```
---
### Task 9: 前端接线(api/router/menu
**Files:**
- Modify: `frontend/src/api/factor.ts`
- Modify: `frontend/src/router/index.ts:25-27` 区域
- Modify: `frontend/src/views/Layout.vue:31-33`(面包屑)与 `:69`(导航数组)与 `:171-174`(菜单)
**Interfaces:**
- Produces(供 Task 10/11/12 消费的 TS API:
```typescript
// —— 追加到 frontend/src/api/factor.ts ——
export interface EvalRun {
run_id: string
label: string
universe: string
symbols_count: number
factors_total: number
factors_done: number
start: string
end: string
status: string
created_at: string
}
export interface MonthlyIcPoint { month: string; ic: number }
export interface LeaderboardRow {
rank: number
factor: string
category: string
expression: string
ic_mean: number | null
icir: number | null
t_stat: number | null
win_rate: number | null
ls_annual: number | null
turnover: number | null
conclusion: string
monthly_ic: MonthlyIcPoint[]
error?: string
}
export interface LeaderboardTiles {
factors_total: number
effective: number
watch: number
top_ls_annual: number | null
top_ls_factor: string | null
}
export interface EvalDetail {
factor: string
category: string
expression: string
metrics: Record<string, unknown>
}
export interface BatchEvalSubmit {
categories: string[]
factors: string[]
symbols: string[]
start: string
end: string
label: string
}
export async function getEvalRuns(): Promise<EvalRun[]> {
const { data } = await apiClient.get<{ runs: EvalRun[] }>('/factor/eval/runs')
return data.runs
}
export async function getLeaderboard(params: {
run_id?: string
period?: string
category?: string
search?: string
sort?: string
order?: string
}): Promise<{ tiles: LeaderboardTiles; rows: LeaderboardRow[] }> {
const { data } = await apiClient.get('/factor/eval/leaderboard', { params })
return data
}
export async function getEvalDetail(factor: string, runId?: string): Promise<EvalDetail> {
const { data } = await apiClient.get<EvalDetail>('/factor/eval/detail', {
params: { factor, ...(runId ? { run_id: runId } : {}) },
})
return data
}
export async function submitBatchEval(req: BatchEvalSubmit): Promise<string> {
const { data } = await apiClient.post<{ task_id: string }>('/factor/eval/submit', req)
return data.task_id
}
```
`router/index.ts``factor/new` 之前插三行:
```typescript
{ path: 'factor/leaderboard', name: 'fc-leaderboard', component: () => import('@/views/factor/Leaderboard.vue') },
{ path: 'factor/leaderboard/:factor', name: 'fc-leaderboard-detail', component: () => import('@/views/factor/LeaderboardDetail.vue') },
{ path: 'factor/batch', name: 'fc-batch', component: () => import('@/views/factor/BatchEval.vue') },
```
`Layout.vue`
- 面包屑数组(31-33 行区域)加:
```typescript
{ match: /^\/factor\/leaderboard\/.+$/, group: '投研', title: '因子详情' },
{ match: /^\/factor\/leaderboard$/, group: '投研', title: 'IC 排行榜' },
{ match: /^\/factor\/batch$/, group: '投研', title: '批量评估' },
```
- 侧边导航数组(69 行区域)加:
```typescript
{ label: 'IC 排行榜', group: '投研', path: '/factor/leaderboard' },
{ label: '批量评估', group: '投研', path: '/factor/batch' },
```
- 菜单(171-174)改为:
```html
<el-sub-menu index="factor">
<template #title><span class="nav-label">投研</span></template>
<el-menu-item index="/factor/leaderboard">IC 排行榜</el-menu-item>
<el-menu-item index="/factor/batch">批量评估</el-menu-item>
<el-menu-item index="/factor/new">因子分析</el-menu-item>
</el-sub-menu>
```
> 注意:先 Read Layout.vue 全文确认数组结构/键名与上面一致再改(摘要行号可能漂移)。此 task 页面文件尚不存在,router 懒加载不会构建失败,但**本 task 不 build**build 统一放 Task 12。
- [ ] **Step 1: 改四个文件**(如上)
- [ ] **Step 2: Commit**
```bash
git add frontend/src/api/factor.ts frontend/src/router/index.ts frontend/src/views/Layout.vue
git commit -m "feat(web): 因子工作台接线——eval API 客户端+3路由+投研组菜单/面包屑 [nas]"
```
---
### Task 10: IC 排行榜页(Leaderboard.vue
**Files:**
- Create: `frontend/src/views/factor/Leaderboard.vue`
**Interfaces:**
- Consumes: Task 9 的 `getEvalRuns/getLeaderboard`、路由 `fc-leaderboard-detail`(行点击 `router.push(`/factor/leaderboard/${factor}?run=${runId}`)`
- 视觉:**逐区块对照 mockup**`docs/factor_research/factor-workbench-mockup.html` 视图①「IC 排行榜」)复刻——4 统计瓦片/筛选栏(run 下拉+类别 chips+搜索框+1D5D10D 切换)/主表格(IC 均值列内嵌横向色条 div 宽度=|ic_mean|×500%、红正绿负按 `--up/--down`/IC 趋势 sparklineinline SVG polyline,取 monthly_ic 后 24 点,正段青负段按 up/down)/信号灯(●`--lamp-ok`=effective ●`--lamp-warn`=watch ●`--lamp-idle`=eliminated
完整组件骨架(样式类名从 mockup 对应区块取值,token 全走 CSS 变量):
```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getEvalRuns, getLeaderboard, type EvalRun, type LeaderboardRow, type LeaderboardTiles } from '@/api/factor'
const router = useRouter()
const runs = ref<EvalRun[]>([])
const runId = ref<string>('')
const period = ref<'1' | '5' | '10'>('1')
const category = ref<string>('') // ''=全部
const search = ref('')
const sort = ref('abs_icir')
const loading = ref(false)
const tiles = ref<LeaderboardTiles | null>(null)
const rows = ref<LeaderboardRow[]>([])
const CATEGORY_CHIPS = [
{ key: '', label: '全部' },
{ key: 'alpha101', label: 'Alpha101' },
{ key: 'alpha158', label: 'Alpha158' },
{ key: 'builtin', label: '内置' },
]
async function load() {
loading.value = true
try {
const data = await getLeaderboard({
...(runId.value ? { run_id: runId.value } : {}),
period: period.value,
...(category.value ? { category: category.value } : {}),
...(search.value ? { search: search.value } : {}),
sort: sort.value,
})
tiles.value = data.tiles
rows.value = data.rows
} finally {
loading.value = false
}
}
function pct(v: number | null): string {
return v == null ? '—' : `${(v * 100).toFixed(1)}%`
}
function num(v: number | null, digits = 3): string {
return v == null ? '—' : v.toFixed(digits)
}
function sparkPath(points: { ic: number }[]): string {
const pts = points.slice(-24)
if (!pts.length) return ''
const w = 72, h = 20
const maxAbs = Math.max(0.02, ...pts.map((p) => Math.abs(p.ic)))
return pts
.map((p, i) => `${(i / Math.max(pts.length - 1, 1)) * w},${h / 2 - (p.ic / maxAbs) * (h / 2 - 2)}`)
.join(' ')
}
const CONCLUSION: Record<string, { label: string; color: string }> = {
effective: { label: '有效', color: 'var(--lamp-ok)' },
watch: { label: '观察', color: 'var(--lamp-warn)' },
eliminated: { label: '淘汰', color: 'var(--lamp-idle)' },
}
onMounted(async () => {
runs.value = await getEvalRuns()
if (runs.value.length) runId.value = runs.value[0].run_id
await load()
})
</script>
```
template 结构(视觉细节照 mockup):页面标题区 → 4 瓦片(`tiles.factors_total/effective/watch/top_ls_annual`+`top_ls_factor`)→ 筛选栏(el-select run / chips / el-input search(回车触发 load/ 1D·5D·10D segmented)→ 表格(列:排名/因子名(副行表达式截断 48 字符)/类别 chip/IC 均值+色条/ICIR/t 值/胜率/多空年化(红正绿跌色)/换手/IC 趋势 svg/信号灯+结论字)→ 行 click 跳详情;`loading` 时 el-loading / 空态「暂无评估结果,去批量评估发起」链接 `/factor/batch`
- [ ] **Step 1: 写组件(对照 mockup 逐区块复刻)**
- [ ] **Step 2: 手验**`cd frontend && npm run dev` 登录后访问 `/factor/leaderboard`(空态可见、筛选交互正常、无 console 报错)
- [ ] **Step 3: Commit**
```bash
git add frontend/src/views/factor/Leaderboard.vue
git commit -m "feat(web): IC排行榜页——瓦片+筛选chips+全指标表格+IC趋势sparkline+信号灯,终端风1:1 mockup [nas]"
```
---
### Task 11: 因子详情页(LeaderboardDetail.vue
**Files:**
- Create: `frontend/src/views/factor/LeaderboardDetail.vue`
**Interfaces:**
- Consumes: `getEvalDetail`、route param `factor`、query `run`;周期切换本地态(默认 1
- Produces: 图表用 echarts(平台既有模式:`import * as echarts from 'echarts'` 或 composables/useChart.ts + `@/utils/echartsDark` 的 darkTitle/darkTooltip/darkGrid/darkAxis,参考 `frontend/src/components/charts/EquityChart.vue` 的用法)
页面结构(对照 mockup 视图②):
1. 返回链接 ← IC 排行榜 + 因子名标题 + 类别 chip + 信号灯
2. 表达式卡片:`--panel` 底、青色 mono、可展开全文
3. 6 格指标条(当前周期):IC 均值 / ICIR / t 值 / 胜率 / 多空年化 / 换手率 + 参评天数(`metrics[period].count`
4. 月度 IC 柱状 + 累计 IC 折线(双 y 或同图双系列 echarts:柱=月度 IC 正红负绿、线=累计 IC 青 `#00e5ff`
5. 十分组年化收益柱状(D1–D10,色阶从 `--down` 渐到 `--up`
6. tears 入口:跳既有 `/factor/new`(提示「 Tears 报告请从因子分析页发起」按钮)——v1 不做深链
7. 周期切换(1D/5D/10D)重渲染 3/4/5 区块
```vue
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getEvalDetail } from '@/api/factor'
const route = useRoute()
const router = useRouter()
const period = ref<'1' | '5' | '10'>('1')
const detail = ref<Awaited<ReturnType<typeof getEvalDetail>> | null>(null)
const error = ref('')
async function load() {
try {
detail.value = await getEvalDetail(String(route.params.factor),
route.query.run ? String(route.query.run) : undefined)
} catch (e) {
error.value = '因子不存在或无评估数据'
}
}
function metrics() {
return (detail.value?.metrics?.[period.value] ?? {}) as Record<string, any>
}
// 月度IC/累计IC 与 十分组 两个 chart 实例:echarts init 于 ref 容器,
// period 变化 watch 后 setOption 重绘(数据源 metrics().monthly_ic / .deciles)
onMounted(load)
watch(period, async () => { await nextTick(); /* setOption 重绘两张图 */ })
</script>
```
- [ ] **Step 1: 写组件**
- [ ] **Step 2: 手验**:dev server 从排行榜点行进入,三区块渲染、周期切换重绘、无 console 报错
- [ ] **Step 3: Commit**
```bash
git add frontend/src/views/factor/LeaderboardDetail.vue
git commit -m "feat(web): 因子详情页——表达式卡+6格指标+月度/累计IC双系列图+十分组柱状,echarts终端暗色 [nas]"
```
---
### Task 12: 批量评估页 + 前端构建门禁(BatchEval.vue + build + CI 扩)
**Files:**
- Create: `frontend/src/views/factor/BatchEval.vue`
- Modify: `.gitea/workflows/ci-cd.yml:57`pytest 命令加 `tests/factor`
**Interfaces:**
- Consumes: `submitBatchEval``getFactors`(类目清单)、既有任务中心路由(提交后跳 `/backtest/history`
- 页面结构(对照 mockup 视图③):01 股票池(3 卡:全A `''` /自定义 symbols textarea——中证800 预设 v1 置灰「未就绪」)→ 02 因子集(按 category 分组勾选卡:alpha101/alpha158/builtin,显示每类因子数,全选/清空)→ 03 时间窗(el-date-picker 起止,默认 2018-01-01 / 2026-06-30;提示文案「全量约 4400 只 × 8.5 年,预计 1–3 小时,完成后结果进 IC 排行榜」)→ 提交按钮(校验:因子集非空)→ 成功 toast + 跳任务中心
- CI`pytest tests/data_platform tests/portfolio tests/factor -q`tests/factor 全是纯逻辑小测,进 CI 门禁防回归)
- [ ] **Step 1: 写组件(如上结构,el-card 分步布局 + 终端风覆盖样式照平台既有表单页如 backtest/New.vue 的覆盖写法)**
- [ ] **Step 2: 完整构建验证(必做,别只 dev)**
Run: `cd frontend && npm run build`
Expected: vue-tsc + vite build 零 errorwarning 可容忍);失败先修再提交
- [ ] **Step 3: 后端全量回归 + CI 文件改**
Run: `venv310/bin/python -m pytest tests/factor tests/api -q`
Expected: 全 PASS
- [ ] **Step 4: Commit**
```bash
git add frontend/src/views/factor/BatchEval.vue .gitea/workflows/ci-cd.yml
git commit -m "feat(web): 批量评估页——股票池/因子集/时间窗三步提交进任务中心;CI门禁加tests/factor [nas]"
```
---
### Task 13: 部署 NAS + 冒烟 + 全量 + 验证
**Files:** 无新文件(操作任务)
- [ ] **Step 1: push 触发 CI**
```bash
git push origin master
```
`http://192.168.2.154:3000/sanguo/sanguo_vnpy_v2/actions`testpytest 含新 tests/factor)→ nas-deploy → nas-verify 全绿。红了就地修(Mac runner 睡眠会导致不跑——确认 caffeinate 在跑)。
- [ ] **Step 2: NAS 容器内冒烟(50 只 × 2024 × 10 因子)**
```bash
ssh sanguo-nas '/var/packages/Docker/target/usr/bin/docker exec sanguo_vnpy_v2 \
python scripts/factor_research/run_eval.py \
--start 2024-01-01 --end 2024-12-31 --limit 50 --label smoke-2024 \
--factors alpha2,alpha6,alpha12,alpha18,kmid,klen,roc_5,ma_20,std_20,wvma_20 \
--db /volume1/stock/sanguo_vnpy_v2/data/factor_eval.db'
```
Expected: `[eval] run_id=ev_... done=10 errors=0`Top15 表格打印,|IC| 普遍 < 0.1。记下 elapsed 秒数(全量外推依据)。
> 冒烟 db 路径显式给 `/volume1/stock/sanguo_vnpy_v2/data/factor_eval.db`(容器挂载卷内、API 容器同进程可见的持久位置)。若容器内该目录不存在,先 `docker exec ... mkdir -p`。**default_eval_db_path() 在容器里指向哪要先验证**`docker exec sanguo_vnpy_v2 python -c "from sanguo_factor.eval_store import default_eval_db_path; print(default_eval_db_path())"`——如果和上面不一致,统一以 `SANGUO_FACTOR_EVAL_DB=/volume1/stock/sanguo_vnpy_v2/data/factor_eval.db` 环境变量注入容器(改 NAS 容器 env 或 app.py 启动处,优先不动 app.py,用 NAS 侧 env)。
- [ ] **Step 3: API 验证**
```bash
TOKEN=$(curl -s -X POST http://192.168.2.154:8000/api/v1/auth/login -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<从 .claude/gitea-config.json 或询问用户>"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])')
curl -s "http://192.168.2.154:8000/api/v1/factor/eval/runs" -H "Authorization: Bearer $TOKEN"
curl -s "http://192.168.2.154:8000/api/v1/factor/eval/leaderboard" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool | head -40
```
Expected: runs 含 smoke-2024status=done);leaderboard tiles.factors_total=10、rows 10 行、无 error 字段。
- [ ] **Step 4: 前端页面验证(chrome-devtools MCP**
打开 `http://192.168.2.154:8000/`(或前端实际端口,问 runbook/nas-verify 用的地址)→ 登录 → 投研→IC 排行榜:冒烟 run 可选、瓦片数字、表格 10 行、信号灯正常 → 点行进详情:三图表渲染 → 批量评估页:表单结构完整。截图留档。
- [ ] **Step 5: 全量评估(走 API 任务中心,非 docker exec——长任务铁律)**
```bash
curl -s -X POST http://192.168.2.154:8000/api/v1/factor/eval/submit \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"categories":["alpha101","alpha158"],"factors":[],"symbols":[],
"start":"2018-01-01","end":"2026-06-30","label":"batch1-full"}'
```
Expected: `{"task_id":"factor_eval_..."}`;任务中心出现任务、stage=批量评估中。预计 1–3 小时(以冒烟实测外推),期间 Monitor 轮询:
```bash
# 每 10 分钟查一次进度(eval_runs.factors_done 递增 = 活着)
ssh sanguo-nas '/var/packages/Docker/target/usr/bin/docker exec sanguo_vnpy_v2 python -c "
import sqlite3; c=sqlite3.connect(\"/volume1/stock/sanguo_vnpy_v2/data/factor_eval.db\");
print(c.execute(\"SELECT run_id,factors_done,factors_total,status FROM eval_runs ORDER BY created_at DESC LIMIT 1\").fetchone())"'
```
- [ ] **Step 6: 全量结果验收**
完成后 leaderboardtiles.factors_total=258、effective/watch 有分布(全 0 effective = 数据或口径有问题要排查);抽 3 个知名因子(alpha101 的 alpha6/alpha12、alpha158 的 ma_20)人工核对 IC 符号量级是否合理(量价反转类因子 A 股 RankIC 1D 通常 |0.03|~|0.1|)。
- [ ] **Step 7: 收尾汇报**
- `git log --oneline` 列出全部 commit(确认每条有标签)
- 向用户汇报:冒烟/全量耗时、Top 因子表、有效/观察/淘汰分布、ST 过滤偏差说明、待推 VPS 清单(按 CLAUDE.md 铁律,**等用户原话「推vps」**)
- 更新 memory `factor-session-mainline.md`
---
## Self-Review 结论
- **Spec 覆盖**:挂载(T2)/vwap(T1)/批量脚本(T6,7)/冒烟先行(T13S2)/缓存扩容→**不需要**(批量路径绕开 AlphaLabSession 的 50 只上限,spec 第 5 条「股票池缓存扩容」由新路径天然满足,零改动)/新 API(T8)/前端三页(T9-12)/验收标准 5 条对应 T13 S2-S6
- **偏差(已声明)**:ST 过滤不做(前视偏差理由);「SQLite 或 CSV+markdown」取 SQLite 单方案
- **类型一致性**metrics/period key "1"/"5"/"10" 全链一致;run_id 贯穿 store/API/前端 query
- **排序稳定性**leaderboard 默认 abs_icir descrank 服务端生成