feat(factor): 改造③size中性化变体+④分数加权rank^γ——③register_szneu_variants生产冠军挂composite_quant12_v2a_szneu(cs_neutralize((v2a),close*share_capital),残差算子线性⇒包裹式≡各源中性化再加权,引擎端到端测试逐点实证;端到端测试显式register_fast_ops钉生产算子语义——fast下alpha16小截面合成数据每日NaN部分股,5股有效对<3会假绿,20股修复)④FactorTopNConfig.weight_gamma目标市值∝(N+1−rank)^γ缺省0退化等权,透传四处+json测试扩六参 [vps]
CI/CD / test (push) Successful in 38s
CI/CD / nas-deploy (push) Successful in 0s
CI/CD / nas-verify (push) Successful in 12s

This commit is contained in:
2026-09-14 23:46:45 +08:00
parent 22bacdb764
commit c29f784e52
6 changed files with 149 additions and 9 deletions
+3 -2
View File
@@ -142,8 +142,9 @@
1. **调仓缓冲带(no-trade band)——✅机制已落地(09-14 夜,TDD)**:只换出跌出前 X% 的持仓(如 top200 持有、跌出 top400 才卖),预期砍换手 11x→3-5x,成本拖累降一个量级——**实盘化的第一前提**。
- 实现:`FactorTopNConfig.band_ratio`(缺省 1.0=硬切,**与原行为逐字节等价**的回归锚);rebalance 3b 步——持仓截面 rank ≤ top_n×band_ratio 则不卖,跌出带才让位,TopN 照常补买;被过滤链剔出的持仓不在 rank 内不受带保护(照卖,与原行为一致)。透传四处:argparse `--band-ratio` + `_build_strategy`/`run_backtest_json`/结果回显(坑 1 纪律,json 测试扩五参钉死)。矩阵待跑:top200 × band{1.5, 2.0} vs base 对照(同批同快照)。
2. **基准配置改 top200**:矩阵内各维度全面最优。零代码(top_n 参数现成),跑批矩阵默认即改。
3. **行业/市值中性化**:治 2024 微盘雪崩失血;四域数据已备(sw_industry 09-14 落位、市值 valuation 域)。
4. 分数加权替代硬切换(进出缓冲的连续版本)。
3. **行业/市值中性化——✅size 变体已落地(09-14 夜,TDD)**:治 2024 微盘雪崩失血;四域数据已备(sw_industry 09-14 落位、市值 valuation 域)。
- 实现:`composite_weighting.register_szneu_variants`——生产冠军自动挂 `composite_quant12_v2a_szneu` = `cs_neutralize((v2a原式), close*share_capital)`,category=composite(评估批自动 join 财务域,share_capital 走 fund_*_neu 已验证链路)。**残差算子线性 ⇒ 包裹式中性化 ≡ 各源中性化再加权**(引擎端到端测试实证:szneu 值逐点 == v2a 值对 size 逐日 OLS 残差)。待跑:szneu 截面导出+top200 回测 vs 本批 band_base 对照。行业中性化(x=行业哑元组)待 sw_industry 数据验收后同式扩展。
4. **分数加权替代硬切换——✅参数已落地(09-14 夜,TDD)**:`FactorTopNConfig.weight_gamma`(缺省 0=等权回归锚):目标市值 ∝ (N+1−rank)^γ(rank∈1..N 为 TopN 内名次),γ>0 头重脚轻——硬切换「进出/留任」二元决策的连续版本。透传四处同 band_ratio(json 测试扩六参钉死)。矩阵待跑:top200 γ=1 vs 等权对照。
---
+29
View File
@@ -41,6 +41,9 @@ _DEFAULT_COMPOSITE_NAME = "composite_quant12_v2a"
_V2B_PROFILE_PATH = Path(__file__).parent / "weight_profiles" / "all18_v2b_v1.json"
_V2B_COMPOSITE_NAME = "composite_all18_v2b"
# ③ size 中性化变体后缀(改造③,§7.3): 对合成总分 cs_neutralize(原式, size)
_SZNEU_SUFFIX = "_szneu"
# ---- ④ L2 覆盖 UI 化(2026-09-14): 档案目录扫描注册 + 建档写路径 ----
_PROFILE_DIR = Path(__file__).parent / "weight_profiles"
_EXPLICIT_COMPOSITE_NAMES = { # 已显式注册(专用名),扫描跳过防双名
@@ -194,6 +197,7 @@ def _register_all() -> None:
if _V2B_PROFILE_PATH.exists():
register_composite_from_profile(load_profile(str(_V2B_PROFILE_PATH)),
_V2B_COMPOSITE_NAME)
register_szneu_variants([_DEFAULT_COMPOSITE_NAME]) # ③ 改造:生产冠军挂 _szneu 变体
register_scanned_profiles()
@@ -202,6 +206,31 @@ def composite_name_for(stem: str) -> str:
return f"composite_{stem}"
def register_szneu_variants(names: list[str]) -> list[str]:
"""③ 市值中性化变体(改造③): 对已注册合成因子挂 <名>_szneu.
表达式 = cs_neutralize((原式), close*share_capital)——残差算子线性,
中性化加权和 ≡ 各源中性化再加权,一处包裹即数学同构;category=composite
使评估批自动 join 财务域(share_capital 可用性走 fund_*_neu 已验证链路).
幂等(已注册跳过);源未注册/未合成类则跳过不炸(返回实际新注册名表).
"""
from .fundamental_neutralize import SIZE_EXPRESSION
registered: list[str] = []
for name in names:
target = name + _SZNEU_SUFFIX
if target in _REGISTRY:
continue
base = get_factor(name)
if base is None or base.get("category") != "composite":
continue
register_factor(
target,
f"cs_neutralize(({base['expression']}), {SIZE_EXPRESSION})",
category="composite")
registered.append(target)
return registered
def register_scanned_profiles(profile_dir: "Path | str | None" = None) -> list[str]:
"""扫描档案目录,把可独立评估的档案注册成常量权重合成因子(返回注册名表).
+7
View File
@@ -86,6 +86,10 @@ def parse_args() -> argparse.Namespace:
"--band-ratio", type=float, default=1.0,
help="factor_topn: 调仓缓冲带(改造①),>1 时持仓 rank≤top_n×ratio 不卖;1.0=硬切",
)
p.add_argument(
"--weight-gamma", type=float, default=0.0,
help="factor_topn: 分数加权(改造④),目标市值∝(N+1−rank)^γ;0=等权",
)
p.add_argument(
"--provider", default="local", choices=["local", "baostock", "miniqmt", "unified"],
help="数据 provider:local(parquet,旧) / baostock(Mac 跨平台) / miniqmt(VPS 实盘) / unified(方案A 权威层)",
@@ -216,6 +220,7 @@ def _build_strategy(args: argparse.Namespace, provider: Any) -> Any:
top_n=getattr(args, "top_n", 100),
rebalance_days=getattr(args, "rebalance_days", 21),
band_ratio=getattr(args, "band_ratio", 1.0),
weight_gamma=getattr(args, "weight_gamma", 0.0),
),
)
# TET Phase 2 验证副本(issue #19):取数走 _ex strict 接口,逻辑与原策略同源 copy
@@ -513,6 +518,7 @@ def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
top_n=int(params.get("top_n", 100)),
rebalance_days=int(params.get("rebalance_days", 21)),
band_ratio=float(params.get("band_ratio", 1.0)),
weight_gamma=float(params.get("weight_gamma", 0.0)),
initial_positions=json.dumps(params["initial_positions"])
if params.get("initial_positions") else "",
)
@@ -860,6 +866,7 @@ def main() -> None:
"top_n": getattr(args, "top_n", 100),
"rebalance_days": getattr(args, "rebalance_days", 21),
"band_ratio": getattr(args, "band_ratio", 1.0),
"weight_gamma": getattr(args, "weight_gamma", 0.0),
})
print(json.dumps(result, ensure_ascii=False, default=str))
else:
+17 -5
View File
@@ -10,7 +10,8 @@
- 过滤链 = 科创北交剔除(实盘约束) + ST + 次新 + 涨跌停/停牌(复用 filters);
- 调仓 = 每 ``rebalance_days`` 个交易日(5/10/21=周/双周/月),T-1 日截面
在 T 日 9:30 开盘调仓(截面值由 T-1 收盘及更早 bar 算出,无前视);
- 持仓 = TopN 等权(order_target_value 等额,与 value_selection 的 buy 同款)。
- 持仓 = TopN 等权(order_target_value 等额,与 value_selection 的 buy 同款);
可选 band_ratio 缓冲带(改造①)/weight_gamma 分数加权(改造④),缺省全退化等权。
方向约定: 因子值大 = 未来收益高(IC 为正的合成因子),TopN 取最大 N 只。
"""
@@ -62,6 +63,7 @@ class FactorTopNConfig:
top_n: int = 100 # 持仓只数
rebalance_days: int = 21 # 每 K 个交易日调仓(5/10/21=周/双周/月)
band_ratio: float = 1.0 # 缓冲带(改造①):>1 时持仓 rank≤top_n×ratio 不卖;1.0=硬切
weight_gamma: float = 0.0 # 分数加权(改造④):目标市值∝(N+1−rank)^γ;0=等权
benchmark: str = "000300.XSHG"
new_stock_days: int = 375 # 次新股过滤阈值(与现有策略同款)
@@ -149,13 +151,13 @@ class FactorTopNStrategy:
# 3b) 缓冲带(改造①): band_ratio>1 时,持仓跌出 TopN 但仍在带内
# (rank ≤ top_n×band_ratio)不卖,跌出带才让位——砍「边缘股反复进出」
# 的换手;被过滤链剔出的持仓不在 rank 内,不受带保护(照卖).
rank_of = {sym: r for r, sym in enumerate(ranked.index, start=1)}
band_keep: List[str] = []
if cfg.band_ratio > 1.0:
band = int(cfg.top_n * cfg.band_ratio)
rank_of = {sym: r for r, sym in enumerate(ranked.index, start=1)}
band_keep = [s for s in positions if rank_of.get(s, band + 1) <= band]
# 4) 调仓: 卖出不在名单且不在带内 → 等额买入(value_selection 同款)
# 4) 调仓: 卖出不在名单且不在带内 → 按权重建仓(value_selection 同款)
for stock in list(positions.keys()):
if stock in buy_list or stock in band_keep:
continue
@@ -165,11 +167,21 @@ class FactorTopNStrategy:
total = _total_value(context)
if total <= 0:
return
per_value = total / len(buy_list)
# ④ 分数加权(改造④): γ>0 时目标市值 ∝ (N+1−rank)^γ(头重脚轻连续版,
# rank∈1..N 为 TopN 内名次);γ=0 退化等权 total/N(原行为).
targets: dict[str, float] = {}
if cfg.weight_gamma > 0.0:
s_w = {s: (len(buy_list) + 1 - rank_of[s]) ** cfg.weight_gamma
for s in buy_list if s in rank_of}
wsum = sum(s_w.values())
if wsum > 0 and len(s_w) == len(buy_list):
targets = {s: total * w / wsum for s, w in s_w.items()}
if not targets:
targets = {s: total / len(buy_list) for s in buy_list}
for stock in buy_list:
if stock in positions:
continue
if self.broker.order_target_value(stock, per_value):
if self.broker.order_target_value(stock, targets[stock]):
positions = _get_positions(context, self.broker)
# =================== 截面读取 ===================
+76
View File
@@ -356,3 +356,79 @@ def test_get_profile_payload_returns_raw_and_resolved(profile_dir):
assert sum(payload["resolved_weights"].values()) == pytest.approx(1.0)
with pytest.raises(ValueError, match="找不到"):
composite_weighting.get_profile_payload("ghost")
# ==================== ③ size 中性化变体(改造③) ====================
def test_szneu_variant_registered_for_v2a():
"""改造③: v2a 注册后自动挂 <名>_szneu——表达式=cs_neutralize((v2a原式), size),
category=composite(评估批自动 join share_capital;TopN 选股直接复用同管线)."""
from sanguo_factor.fundamental_neutralize import SIZE_EXPRESSION
fac = get_factor("composite_quant12_v2a_szneu")
assert fac is not None and fac["category"] == "composite"
v2a = get_factor("composite_quant12_v2a")
assert fac["expression"] == \
f"cs_neutralize(({v2a['expression']}), {SIZE_EXPRESSION})"
def test_szneu_registration_idempotent():
"""幂等: 重复 _register_all() 不重复注册不漂移(autouse fixture 已跑多次)."""
composite_weighting._register_all()
from sanguo_factor.registry import _REGISTRY
assert "composite_quant12_v2a_szneu" in _REGISTRY
composite_weighting._register_all()
fac = get_factor("composite_quant12_v2a_szneu")
assert fac is not None
def test_szneu_expression_evaluates_end_to_end():
"""引擎端到端(生产算子语义): szneu 全表达式可求值,且数值 == v2a 值对
size 逐日 OLS 残差(残差算子线性 ⇒ 包裹式中性化 ≡ 先合成再残差化实证).
20 股合成:fast ops 下 alpha16 在小截面合成数据上每日 NaN 掉部分股
(真实数据无此退化,生产 parquet 全密),5 股会让有效对 <3(_MIN_SAMPLES)
使 cs_neutralize 全空——20 股保足逐日 ≥3 有效对.
显式 register_fast_ops(): batch_eval 生产路径必注册,不依赖测试顺序
(suite 内其它批评估测试注册过 fast 算子,单跑则慢算子——两种语义
NaN 域不同,不钉死会顺序性飘绿飘红).
"""
import numpy as np
import pandas as pd
from sanguo_factor.fast_ops import register_fast_ops
from vnpy.alpha.dataset.utility import calculate_by_expression
register_fast_ops()
rng = np.random.default_rng(42)
stocks = [f"s{i:03d}.SSE" for i in range(20)]
days = pd.bdate_range("2023-01-02", periods=130)
rows, px = [], {s: 8.0 * (i + 1) for i, s in enumerate(stocks)}
for day in days:
for s in stocks:
px[s] = max(px[s] * (1 + rng.normal(0, 0.02)), 0.5)
o = px[s] * (1 + rng.normal(0, 0.005))
h = max(o, px[s]) * (1 + abs(rng.normal(0, 0.004)))
lo = min(o, px[s]) * (1 - abs(rng.normal(0, 0.004)))
rows.append({"datetime": day, "vt_symbol": s, "open": o, "high": h,
"low": lo, "close": px[s],
"volume": float(np.exp(rng.normal(10, 0.4))),
"vwap": (h + lo + px[s]) / 3.0,
"share_capital": float(rng.normal(10, 1))})
import polars as pl
df = pl.DataFrame(rows).with_columns(pl.col("datetime").cast(pl.Datetime("us")))
def _eval(expr: str):
out = calculate_by_expression(df, expr).to_pandas()
return out.sort_values(["datetime", "vt_symbol"]).reset_index(drop=True)
got = _eval(get_factor("composite_quant12_v2a_szneu")["expression"])
raw = _eval(get_factor("composite_quant12_v2a")["expression"])
base = df.select(["datetime", "vt_symbol", "close", "share_capital"]).to_pandas()
m = got.merge(raw, on=["datetime", "vt_symbol"], suffixes=("_n", "_r"))
m = m.merge(base, on=["datetime", "vt_symbol"])
m = m.dropna(subset=["data_n", "data_r"])
assert len(m) > 100 # 130 日 × 20 股的主流日全覆盖
m["size"] = m["close"] * m["share_capital"]
for _dt, g in m.groupby("datetime"):
x, y = g["size"].to_numpy(), g["data_r"].to_numpy()
b = ((x - x.mean()) * (y - y.mean())).sum() / ((x - x.mean()) ** 2).sum()
resid = y - (y.mean() + b * (x - x.mean()))
np.testing.assert_allclose(g["data_n"].to_numpy(), resid, atol=1e-9)
+17 -2
View File
@@ -214,7 +214,7 @@ def test_run_backtest_json_passes_factor_topn_params(monkeypatch):
captured.update({
k: getattr(args, k, "<MISSING>")
for k in ("factor_name", "factor_dir", "top_n",
"rebalance_days", "band_ratio")
"rebalance_days", "band_ratio", "weight_gamma")
})
return {}
@@ -223,6 +223,21 @@ def test_run_backtest_json_passes_factor_topn_params(monkeypatch):
"strategy": "factor_topn",
"factor_name": "composite_test", "factor_dir": "/x/y",
"top_n": 50, "rebalance_days": 10, "band_ratio": 1.5,
"weight_gamma": 1.0,
})
assert captured == {"factor_name": "composite_test", "factor_dir": "/x/y",
"top_n": 50, "rebalance_days": 10, "band_ratio": 1.5}
"top_n": 50, "rebalance_days": 10, "band_ratio": 1.5,
"weight_gamma": 1.0}
def test_rebalance_weight_gamma_tilts_to_top(factor_dir):
"""④ 分数加权: γ>0 时目标市值 ∝ (N+1−rank)^γ(N=TopN 只数);
γ=1 → rank1/2/3 权重 = 3:2:1 = 0.5/1/3/1/6(头重脚轻连续版,缺省 γ=0 等权
由 test_rebalance_topn_and_universe 钉死)."""
st, otv = make_strategy(factor_dir, top_n=3, weight_gamma=1.0)
st.rebalance(_ctx("2024-10-08"))
vals = {c.args[0]: c.args[1] for c in otv.call_args_list}
# 09-30 截面 rank: 300002(6)=1, 300001(5)=2, 000002(4)=3
assert vals["300002.XSHE"] == pytest.approx(1e6 * 3 / 6)
assert vals["300001.XSHE"] == pytest.approx(1e6 * 2 / 6)
assert vals["000002.XSHE"] == pytest.approx(1e6 * 1 / 6)