Files
tick-stock-panel/backend/tests/test_custom_signals_factor_conditions.py
T
shy3130 e0cd625ef4 feat(platform): 因子平台与因子↔策略双向联动 v0.2.3
- 因子平台: /factors 一级页(检验/因子库/编辑器/组合/挖掘), DSL 公式因子(25 算子点选、双语字段、我的因子模板、脏公式守卫), 版本与生命周期, 自动挖掘 L1 统计筛选
- 因子↔策略四条桥: 触发器 Zap 快建因子条件信号、因子一键生成排名策略、自定义信号 AI 提示词接入因子分组、策略回测因子归因(胜/败单入场信号日因子均值, 独立 tab, 双语因子名)
- 回测: 统计卡新增盈亏比(≥1 红/<1 绿), 蒙卡回撤合并为中位/95% 双值卡(自适应字号), 高级设置基础过滤与策略编辑器参数对齐(5 组区间)
- 信号库独立页 /signals(原设置 tab 迁出), 持仓提醒入导航; 挖掘并入因子页第 5 tab, /mining 旧链接重定向
- 研究线配套: 因子目录 61→77(评分/矩阵双内核), stats_v2(Newey-West/BH-FDR/DSR), enriched 管道与异动/报价服务配套调整
- 文档: README 导航与特性表、features.md 因子平台章节、操作说明书 9.2、factor-platform-plan 执行状态与 §5、二开文档桥接说明; 交流与支持节改版
- 版本 0.2.2 → 0.2.3; 后端全量 1625 passed(1 例环境性跳过), 前端 build 通过
2026-09-05 15:41:15 +08:00

93 lines
3.6 KiB
Python

"""自定义信号因子条件 — 字段白名单动态化 + 因子列物化链路。
因子接入规则 (P3): 条件可引用注册表因子 (虚拟/自定义/复合), 历史路径由
materialize_factor_columns 复用评分物化管线补算, 与检验/评分同一条计算逻辑。
"""
from __future__ import annotations
from datetime import date, timedelta
import polars as pl
import pytest
from app.strategy import custom_signals
def _frame(n_days: int = 30) -> pl.DataFrame:
"""两标的收盘价缓涨; ma20 手工预置为 0.9 倍滚动均值 (保证乖离恒为正)。"""
rows = []
end = date(2026, 8, 31)
for symbol_id, base in (("A", 100.0), ("B", 50.0)):
for i in range(n_days):
rows.append({
"symbol": symbol_id,
"date": end - timedelta(days=n_days - 1 - i),
"close": base * (1.0 + 0.001 * i),
})
df = pl.DataFrame(rows).sort(["symbol", "date"])
return df.with_columns(
(pl.col("close").rolling_mean(20).over("symbol") * 0.9).alias("ma20")
)
def test_allowed_fields_union_registry() -> None:
allowed = custom_signals.allowed_fields()
assert "close" in allowed # 物化白名单保留
assert "ma20_bias" in allowed # 虚拟因子
assert "turnover_z_60d" in allowed # 虚拟因子 (61 日预热)
assert "momentum_20d" in allowed # 既是白名单列也是基础因子
assert "nope_col" not in allowed
# 静态白名单不受污染 (供 monitor_rules 等仍按物化列口径使用)
assert "ma20_bias" not in custom_signals.ALLOWED_FIELDS
def test_validate_accepts_and_rejects_factor_fields() -> None:
ok = {
"id": "t_bias_low", "name": "乖离超卖", "kind": "entry",
"conditions": [{"left": "ma20_bias", "op": "<", "right": "-0.05", "leftDays": 0, "rightDays": 0}],
}
custom_signals.validate(ok) # 不抛错即通过
bad = {
"id": "t_bad", "name": "x", "kind": "entry",
"conditions": [{"left": "not_a_field", "op": "<", "right": "1"}],
}
with pytest.raises(ValueError):
custom_signals.validate(bad)
def test_materialize_factor_columns_and_inject() -> None:
sig = {
"id": "bias_high", "name": "乖离偏高", "kind": "entry", "enabled": True,
"conditions": [{"left": "ma20_bias", "op": ">", "right": "0", "leftDays": 0, "rightDays": 0}],
}
exprs = custom_signals.build_expressions([sig])
col = custom_signals.column_name("bias_high")
assert col in exprs
df = _frame()
assert "ma20_bias" not in df.columns
df2 = custom_signals.materialize_factor_columns(df, exprs)
assert "ma20_bias" in df2.columns # 复用评分物化路径补算
# ma20 = 0.9 x 滚动均值 → 窗口内乖离恒 > 0
warm = df2.filter(pl.col("ma20_bias").is_not_null())
assert warm.height > 0
assert (warm["ma20_bias"] > 0).all()
injected = custom_signals.inject(df2, exprs)
assert col in injected.columns
hit = injected.filter(pl.col("ma20_bias").is_not_null())
assert hit[col].all() # 条件在窗口内全部成立
def test_materialize_skips_unknown_columns() -> None:
"""非注册表缺失列: 物化不处理不报错, 由 inject 缺列告警跳过。"""
sig = {
"id": "t_unknown", "name": "x", "kind": "entry", "enabled": True,
"conditions": [{"left": "not_a_field", "op": "<", "right": "1", "leftDays": 0, "rightDays": 0}],
}
exprs = custom_signals.build_expressions([sig]) # 编译不做白名单校验 (validate 负责)
df = _frame()
df2 = custom_signals.materialize_factor_columns(df, exprs)
assert df2.columns == df.columns