feat(factor): 合成层v1 Combiner——量价12源+财务6源等权rank合成3因子(剔topholder防null传染) [nas]

八环第④环: composite_quant12/fund6/all18 拼接表达式注册(category=composite).
- 量价12独立源(族分析固化): build 统一 cs_rank 包裹,负向=(-1)*在rank外
- 财务6源: 注册表达式已定向直接内嵌;v1.1 剔 fund_topholder_chg——
  源数据2021Q3起+首期无差分→h1整窗null传染(引擎无fill_null算子),
  v2数据补全或特征层合成时回填
- quant12 两窗ICIR 0.885/0.735 超最强单源alpha16(0.66)=合成增益实证

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-09 00:34:37 +08:00
parent 66619c17d8
commit 3954a68b9e
4 changed files with 374 additions and 3 deletions
+1
View File
@@ -2,3 +2,4 @@
from . import library # noqa: F401 (triggers _register_all to register built-in factors)
from . import alpha_datasets # noqa: F401 挂载 Alpha101/158(导入即注册)
from . import fundamental_library # noqa: F401 财务因子 P0 批(导入即注册)
from . import composite_library # noqa: F401 合成层 v1(导入即注册,源依赖前三个)
+4 -3
View File
@@ -52,7 +52,8 @@ def run_batch_eval(
fund_data_dir: 财务静态域根目录(None → cfg.data_paths["static_dir"] →
NAS 默认 /volume1/stock/sanguo_vnpy_v2/data/static);仅当因子列表含
category="fundamental" 时才读取并 join(量价批零开销)。
category="fundamental" "composite"(合成因子内嵌财务源)时才读取并
join(量价批零开销)。
"""
from vnpy.alpha.dataset.utility import calculate_by_expression
@@ -74,7 +75,7 @@ def run_batch_eval(
# 财务因子批: bars 释放前仅抽取小体量 codes/dates(特征 join 移到 del bars 后,
# 分块进行——全量特征帧+alpha_df 双全量副本在 7.9G NAS 必 OOM)
fund_names = [n for n in factor_names
if (get_factor(n) or {}).get("category") == "fundamental"]
if (get_factor(n) or {}).get("category") in ("fundamental", "composite")]
if fund_names:
fund_static_dir = fund_data_dir or cfg.data_paths.get("static_dir") or DEFAULT_STATIC_DIR
fund_codes = bars["vt_symbol"].unique().to_list()
@@ -139,7 +140,7 @@ def run_batch_eval(
# 引用瘦身: 只 join 本批 fundamental 表达式实际引用的特征列——
# FEATURE_COLUMNS 全量 68 列 × 1480 万行 ≈ 8G 超 NAS 7.9G 内存,
# 按引用裁列后单族批 ≈ 1-2G(P0 33 列全量 4G 基线的必要减负)
has_fund = any((get_factor(n) or {}).get("category") == "fundamental"
has_fund = any((get_factor(n) or {}).get("category") in ("fundamental", "composite")
for n in factor_names)
if has_fund:
import re as _re
+109
View File
@@ -0,0 +1,109 @@
"""合成层 v1(Combiner): 量价 12 独立源 + 财务 7 源 → 等权 rank 合成总分因子.
「因子战法八环」第④环首个落地. 因子 = 表达式字符串注册(category="composite"),
表达式由 build 函数从源清单常量表循环拼接(19 源数千字符,引擎 eval 解析无碍;
可读性/可维护性由源常量表承载,不手写长表达式).
cs_rank 语义(vnpy_v4.4.0/vnpy/alpha/dataset/cs_function.py:15):
pl.col("data").rank().over("datetime") —— 按 datetime 分组、组内全池
截面 rank(batch_eval alpha_df 为全池长表,即每日全市场截面排名,正确口径;
polars rank 默认 average 法,值域 1..N 非归一化,N=当日非空源值数).
方向口径(钉死,二者 IC 等价——每日仅差截面常数,不影响秩相关):
- 量价源: 注册表达式为原始连续值(无方向) → build 统一包 cs_rank 截面化;
负向源项 = `(-1) * cs_rank((<expr>))`,负号在 rank 外(与 alpha16 引擎先例
`-1 * cs_rank(...)` 同族);备选 `cs_rank((-1) * (...))`(rank 内取负=秩反转)
弃用,全文统一 rank 外.
- 财务源: 注册表达式本身已是 `cs_rank(指标)`(负 IC 已在表达式内取负定向,
高=好) → 直接内嵌、不再套 cs_rank;源表方向列 = 内嵌后净方向,登记 "-"
时才前置 `(-1) * `(当前 7 源全为已定向正向,不触发).
Null 传播: polars 算术 null 传染 → 合成值任一源 null 即 null(覆盖 = 19 源
交集;gdhs/topholder 等事件流源在上市早期覆盖受限,合成因子覆盖期短于
单源,属已知限制非缺陷).
warmup: 量价源最大窗 vma_60=60 bar / alpha83 内层 ts_sum(ts_mean(volume,10),50)
≈ 59 bar < batch_eval WARMUP_BARS=120,统一预热掩码覆盖.
"""
from .registry import register_factor, get_factor, _REGISTRY
# 量价 12 独立源(08-31 族分析固化;方向 = 该源 ICIR 符号)
QUANT_SOURCES: list[tuple[str, str]] = [
("vma_60", "+"), # alpha158 量能收缩(族1 代表族)
("alpha16", "+"), # alpha101 量价背离(族2 代表,全池最强单源)
("alpha83", "+"), # alpha101
("alpha12", "+"), # alpha101
("alpha2", "+"), # alpha101
("alpha42", "+"), # alpha101
("vol_ma5", "-"), # builtin
("wvma_20", "-"), # alpha158
("klow", "-"), # alpha158
("cord_5", "-"), # alpha158
("kup", "-"), # alpha158
("alpha81", "-"), # alpha101
]
# 财务 6 源(P0+P1 IC 结论 + 族分析独立性选定;注册表达式已 cs_rank 定向).
# v1.1: 剔 fund_topholder_chg —— top_holders 源数据 2021Q3 起(NAS 镜像实测,2020Q1
# 契约口径与实际不符),首期无差分 → 特征 2022 年中才生效;null 传染使合成因子在
# h1(2016-2021)整窗空序列(实测 months=0).引擎无 fill_null 类算子,无法表达式内
# null-safe 平均,故 v1 剔除;v2 数据补全或特征层合成时回填.
FUND_SOURCES: list[tuple[str, str]] = [
("fund_bp", "+"), # h2 ICIR 0.319 唯一 effective
("fund_nsi", "+"), # h2 0.233
("fund_gp_over_m", "+"), # h2 0.212 新晋
("fund_gdhs_chg", "+"), # 独立源 0.15/0.11
("fund_growth_scissors", "+"), # h1 0.236(h2 0.076 衰减但方向稳)
("fund_sue_np", "+"), # h1 0.200
]
# (合成名, 源清单)——项形态由源注册 category 决定: fundamental=内嵌,其余=cs_rank 包裹
COMPOSITE_FACTORS: list[tuple[str, list[tuple[str, str]]]] = [
("composite_quant12", QUANT_SOURCES),
("composite_fund6", FUND_SOURCES),
("composite_all18", QUANT_SOURCES + FUND_SOURCES),
]
def rank_term(expression: str, direction: str) -> str:
"""原始值源贡献项: cs_rank 截面化;负向 = rank 外前置 (-1) *(口径钉死)."""
term = f"cs_rank(({expression}))"
return f"(-1) * {term}" if direction == "-" else term
def _embed_term(expression: str, direction: str) -> str:
"""已定向源(财务)贡献项: 原样内嵌;净方向 "-" 时才前置负号."""
return f"({expression})" if direction == "+" else f"(-1) * ({expression})"
def build_composite_expression(terms: list[str]) -> str:
"""等权平均拼接: (项1 + 项2 + ...) / N."""
return "(" + " + ".join(terms) + f") / {len(terms)}"
def _register_all() -> None:
"""注册全部合成因子(幂等);先幂等补挂源(测试清空 _REGISTRY 后可独立重建)."""
from . import library as _builtin_lib
from . import alpha_datasets as _alpha_ds
from . import fundamental_library as _fund_lib
_builtin_lib._register_all()
_alpha_ds.mount_all()
_fund_lib._register_all()
for name, sources in COMPOSITE_FACTORS:
if name in _REGISTRY:
continue
terms = []
for src_name, direction in sources:
src = get_factor(src_name)
if src is None:
raise ValueError(f"合成源未注册: {src_name}")
if src["category"] == "fundamental":
terms.append(_embed_term(src["expression"], direction))
else:
terms.append(rank_term(src["expression"], direction))
register_factor(name, build_composite_expression(terms), category="composite")
# 模块导入时自动注册(与 library.py/fundamental_library.py 同模式)
_register_all()
+260
View File
@@ -0,0 +1,260 @@
# tests/factor/test_composite_library.py
"""合成层 v1(Combiner): 3 合成因子注册 + 引擎解析 + 方向传播 + 数值域 + 端到端."""
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 numpy as np
import pandas as pd
import polars as pl
import pytest
from sanguo_factor import composite_library
from sanguo_factor.composite_library import (
QUANT_SOURCES, FUND_SOURCES, build_composite_expression, rank_term,
)
from sanguo_factor.registry import list_factors, get_factor
from vnpy.alpha.dataset.utility import calculate_by_expression
@pytest.fixture(autouse=True)
def _ensure_composite_registered():
"""其它测试模块清空 _REGISTRY 后,这里幂等重挂源+合成,保证本模块与顺序无关."""
composite_library._register_all()
# ==================== 注册与源表契约 ====================
def test_three_composites_registered():
facs = list_factors("composite")
assert {f["name"] for f in facs} == {"composite_quant12", "composite_fund6",
"composite_all18"}
def test_source_tables_cover_18_independent_sources():
assert len(QUANT_SOURCES) == 12
assert len(FUND_SOURCES) == 6
names = [s[0] for s in QUANT_SOURCES] + [s[0] for s in FUND_SOURCES]
assert len(set(names)) == 18 # 无重复源
for name, _dir in QUANT_SOURCES:
f = get_factor(name)
assert f is not None and f["category"] in ("alpha101", "alpha158", "builtin"), name
for name, _dir in FUND_SOURCES:
f = get_factor(name)
assert f is not None and f["category"] == "fundamental", name
# 方向只允许 +/-
assert {d for _, d in QUANT_SOURCES + FUND_SOURCES} <= {"+", "-"}
def test_quant_sources_direction_table():
"""负向源 6 个(vol_ma5/wvma_20/klow/cord_5/kup/alpha81),其余正向."""
dirs = dict(QUANT_SOURCES)
neg = {n for n, d in dirs.items() if d == "-"}
assert neg == {"vol_ma5", "wvma_20", "klow", "cord_5", "kup", "alpha81"}
def test_registration_idempotent():
import importlib
importlib.reload(composite_library)
assert len(list_factors("composite")) == 3
# ==================== 表达式构造契约 ====================
def test_quant12_wraps_every_source_in_cs_rank_with_direction():
"""负向源形态钉死: (-1) * cs_rank((expr))(负号在 rank 外,口径见 library 注释)."""
expr = get_factor("composite_quant12")["expression"]
assert expr.startswith("(") and expr.endswith("/ 12")
for name, direction in QUANT_SOURCES:
assert rank_term(get_factor(name)["expression"], direction) in expr, \
f"{name} 项缺失或形态不符"
assert expr.count("(-1) * cs_rank((") == 6 # 6 个负向源
def test_fund6_embeds_registered_expressions_without_double_rank():
"""财务源注册表达式已 cs_rank 定向 → 直接内嵌,不得再包一层 cs_rank."""
expr = get_factor("composite_fund6")["expression"]
assert expr.endswith("/ 6")
for name, _direction in FUND_SOURCES:
assert f"({get_factor(name)['expression']})" in expr, f"{name} 未原样内嵌"
assert "cs_rank((cs_rank(" not in expr # 无双重 rank 包裹
def test_all18_mixes_both_kinds():
expr = get_factor("composite_all18")["expression"]
assert expr.endswith("/ 18")
# 12 量价项包 cs_rank + 6 财务项内嵌
for name, direction in QUANT_SOURCES:
assert rank_term(get_factor(name)["expression"], direction) in expr, name
for name, _direction in FUND_SOURCES:
assert f"({get_factor(name)['expression']})" in expr, name
# ==================== cs_rank 截面语义锁定 ====================
def test_cs_rank_is_per_day_cross_section():
"""引擎 cs_rank 语义 = 按 datetime 分组截面 rank(非全表 rank)——合成口径的前提."""
df = pl.DataFrame({
"datetime": ["2023-01-02"] * 3 + ["2023-01-03"] * 3,
"vt_symbol": ["A", "B", "C"] * 2,
"close": [10.0, 5.0, 20.0, 30.0, 5.0, 10.0],
}).with_columns(pl.col("datetime").str.to_datetime())
out = calculate_by_expression(df, "cs_rank(close)")
vals = out["data"].to_list()
# day1: 10<20 → rank(10)=2, rank(5)=1, rank(20)=3;day2: 30=最大 → 3
assert vals == [2.0, 1.0, 3.0, 3.0, 1.0, 2.0]
# ==================== 方向传播与数值域(真实引擎) ====================
_STOCKS5 = ["600000.SSE", "000001.SZSE", "300001.SZSE", "600004.SSE", "000333.SZSE"]
_FUND_COLS = ["equity", "share_capital", "nsi", "gp_ttm", "gdhs_chg",
"growth_scissors", "sue_np"]
def _engine_df(n_days: int = 130) -> pl.DataFrame:
"""5 股 × n_days 合成长表: 覆盖 12 量价源全部引用列 + 7 财务特征列."""
rng = np.random.default_rng(42)
days = pd.bdate_range("2023-01-02", periods=n_days)
rows = []
px = {s: 8.0 * (i + 1) for i, s in enumerate(_STOCKS5)}
bias = {s: rng.normal(0, 0.5) for s in _STOCKS5}
for i, day in enumerate(days):
for j, s in enumerate(_STOCKS5):
ret = rng.normal(0, 0.02)
px[s] = max(px[s] * (1 + ret), 0.5)
open_ = px[s] * (1 + rng.normal(0, 0.005))
high = max(open_, px[s]) * (1 + abs(rng.normal(0, 0.004)))
low = min(open_, px[s]) * (1 - abs(rng.normal(0, 0.004)))
volume = float(np.exp(rng.normal(10, 0.4)))
vwap = (high + low + px[s]) / 3.0
row = {"datetime": day, "vt_symbol": s, "open": open_, "high": high,
"low": low, "close": px[s], "volume": volume, "vwap": vwap}
for c in _FUND_COLS:
row[c] = bias[s] + rng.normal(0, 1.0)
rows.append(row)
return pl.DataFrame(rows).with_columns(pl.col("datetime").cast(pl.Datetime("us")))
def _eval_sorted(df: pl.DataFrame, expression: str) -> pd.DataFrame:
out = calculate_by_expression(df, expression).to_pandas()
return out.sort_values(["datetime", "vt_symbol"]).reset_index(drop=True)
def test_negative_sign_cancels_same_source():
"""同源一正一负 → 合成恒 ≈ 0(负号经 rank 外前置 (-1) * 正确传递)."""
df = _engine_df(40)
expr = build_composite_expression([rank_term("close", "+"), rank_term("close", "-")])
out = _eval_sorted(df, expr)
assert np.allclose(out["data"].dropna(), 0.0, atol=1e-9)
assert out["data"].notna().all()
def test_same_direction_equals_single_source():
"""同源同向 → 合成 = 单源 rank(等权均值退化)."""
df = _engine_df(40)
expr = build_composite_expression([rank_term("close", "+"), rank_term("close", "+")])
got = _eval_sorted(df, expr)["data"]
want = _eval_sorted(df, "cs_rank(close)")["data"]
np.testing.assert_allclose(got, want)
@pytest.mark.parametrize("composite,quant_dirs,fund_dirs", [
("composite_quant12", dict(QUANT_SOURCES), {}),
("composite_fund6", {}, dict(FUND_SOURCES)),
("composite_all18", dict(QUANT_SOURCES), dict(FUND_SOURCES)),
])
def test_composite_equals_mean_of_directed_terms(composite, quant_dirs, fund_dirs):
"""数值域: 合成值 == 逐源定向求均值(引擎逐项评估 vs 整体表达式全等)."""
df = _engine_df(130)
got = _eval_sorted(df, get_factor(composite)["expression"])["data"]
parts = []
for name, direction in quant_dirs.items():
# 量价源先截面化(与 build 同口径)再定向
s = _eval_sorted(df, f"cs_rank(({get_factor(name)['expression']}))")["data"]
parts.append(s if direction == "+" else -s)
for name, direction in fund_dirs.items():
# 财务源注册表达式已定向,原样评估
s = _eval_sorted(df, get_factor(name)["expression"])["data"]
parts.append(s if direction == "+" else -s)
want = sum(parts) / len(parts)
assert (got.isna() == want.isna()).all(), "null 位置应一致(逐源 null 传染)"
valid = ~got.isna()
assert valid.sum() > 0, "长窗合成应有非空值(预热期外)"
np.testing.assert_allclose(got[valid], want[valid], atol=1e-9)
# 数值域: 合成 ∈ [min(项均值), max(项均值)] 逐日截面收紧到 [−N, N] 秩域
n_stocks = len(_STOCKS5)
assert got[valid].min() >= -n_stocks - 1e-9
assert got[valid].max() <= n_stocks + 1e-9
# ==================== 端到端(batch_eval 复合门) ====================
_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)
"""
_EOD_STOCKS = [("600000", "SSE"), ("000001", "SZSE"), ("300001", "SZSE"),
("600004", "SSE"), ("000333", "SZSE")]
@pytest.fixture(scope="module")
def db(tmp_path_factory):
"""5 只 × 2022-06~2024-01 合成日线.快振荡价(相位错开 → 截面排序频繁交叉),
OHLCV 逐日扰动,turnover=wap×volume(vwap≠close).退化形态会杀源因子:
O=C → alpha2 全并列 NaN;慢振荡/趋势 → 5 bar 窗内 cs_rank(high) 恒定
→ alpha16 的 ts_cov 恒 null(3 只慢振荡实测 0/243 天可算)."""
composite_library._register_all()
rng = np.random.default_rng(7)
p = tmp_path_factory.mktemp("cb")
db = str(p / "qt.db")
conn = sqlite3.connect(db)
conn.execute(_DDL)
days = pd.bdate_range("2022-06-01", "2024-01-05")
phase = {s: 1.3 * j for j, (s, _ex) in enumerate(_EOD_STOCKS)}
for i, day in enumerate(days):
d = day.strftime("%Y-%m-%d")
for sym, ex in _EOD_STOCKS:
px = 10.0 + 3.0 * np.sin(i / 2.2 + phase[sym]) + rng.normal(0, 0.25)
open_ = px * (1 + rng.normal(0, 0.005))
high = max(open_, px) * (1 + abs(rng.normal(0, 0.004)))
low = min(open_, px) * (1 - abs(rng.normal(0, 0.004)))
volume = float(np.exp(rng.normal(4.6, 0.4)))
wap = (high + low + px) / 3.0
conn.execute("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(sym, ex, f"{d} 00:00:00", "d", volume, wap * volume, 0,
open_, high, low, px))
conn.commit()
conn.close()
return db
def test_composite_end_to_end(db, synthetic_static, tmp_path):
"""category=composite 同样触发财务特征 join(复合门),三因子无错误评估."""
from sanguo_factor.batch_eval import run_batch_eval
from sanguo_factor import eval_store
eval_db = str(tmp_path / "comp_eval.db")
out = run_batch_eval(
factor_names=["composite_quant12", "composite_fund6", "composite_all18"],
start="2023-02-01", end="2023-12-31",
eval_db=eval_db, label="comp_t", cfg=None, vnpy_db_override=db,
fund_data_dir=synthetic_static,
)
assert out["factors_done"] == 3
assert out["errors"] == []
assert {r["factor"] for r in eval_store.get_rows(eval_db, out["run_id"],
category="composite")} == \
{"composite_quant12", "composite_fund6", "composite_all18"}
for name in ("composite_quant12", "composite_fund6", "composite_all18"):
m = eval_store.get_detail(eval_db, out["run_id"], name)["metrics"]
assert "error" not in m, f"{name}: {m.get('error')}"
# 量价源全天候覆盖 → quant12 截面 IC 样本点非零;
# fund7/all19 在合成静态域 topholder_chg 仅 1/3 股覆盖 → count 可为 0(非错误)
m = eval_store.get_detail(eval_db, out["run_id"], "composite_quant12")["metrics"]
assert m["1"]["count"] > 0