mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 22:34:18 +08:00
- 因子平台: /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 通过
210 lines
8.1 KiB
Python
210 lines
8.1 KiB
Python
"""因子公式 DSL 编译器测试 (P2)。
|
|
|
|
覆盖: 全部编译期错误码 E001-E008/E010/E011/E014/E016、窗口纪律 (只向后看)、
|
|
数值正确性 (与手算基准对拍)、依赖/预热推导、运行期 fail-closed。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.factors.dsl import BASE_COLUMNS, FACTOR_COLUMN, compile_formula
|
|
|
|
|
|
def _panel() -> pl.DataFrame:
|
|
# 两个 symbol x 6 日, 便于验证 over("symbol") 不串组
|
|
rows = []
|
|
for symbol, closes in (("A", [10.0, 11.0, 12.0, 13.0, 14.0, 15.0]),
|
|
("B", [100.0, 90.0, 80.0, 70.0, 60.0, 50.0])):
|
|
for index, close in enumerate(closes):
|
|
rows.append({
|
|
"symbol": symbol,
|
|
"date": date(2026, 1, index + 1),
|
|
"close": close,
|
|
"volume": 1000.0 + index * 100,
|
|
"amount": (1000.0 + index * 100) * close,
|
|
})
|
|
return pl.DataFrame(rows).sort(["symbol", "date"])
|
|
|
|
|
|
def _eval(formula: str, panel: pl.DataFrame | None = None) -> pl.Series:
|
|
compiled = compile_formula(formula)
|
|
assert compiled.ok, [error.to_dict() for error in compiled.errors]
|
|
assert compiled.frame_transform is not None
|
|
base = panel if panel is not None else _panel()
|
|
frame = compiled.frame_transform(base)
|
|
assert frame is not None
|
|
return frame[FACTOR_COLUMN]
|
|
|
|
|
|
# ------------------------------------------------------------- 错误码覆盖
|
|
|
|
@pytest.mark.parametrize("formula, code", [
|
|
("clos + 1", "E001"), # 未知标识符
|
|
("foo(close)", "E002"), # 未知函数
|
|
("ts_mean(close)", "E003"), # 缺窗口参数
|
|
("ts_mean(close, close)", "E003"), # 窗口参数必须是常量
|
|
("ts_mean(close, 1)", "E004"), # 窗口 < 2
|
|
("ts_mean(close, 600)", "E004"), # 窗口 > 512
|
|
("ts_quantile(close, 5, 1.5)", "E004"), # q 不在 (0,1)
|
|
("ts_delay(close, -5)", "E005"), # 负 shift = 未来函数
|
|
("ts_delta(close, -1)", "E005"),
|
|
("power(close, 5)", "E010"), # 指数越界
|
|
("winsorize(close, 9)", "E011"), # k 越界
|
|
("close / 0", "E008"), # 静态除零
|
|
("1 + 2", "E016"), # 常量表达式
|
|
("close +", "E014"), # 语法错误
|
|
("(close", "E014"),
|
|
("close $ 1", "E014"),
|
|
("", "E014"),
|
|
])
|
|
def test_error_codes(formula: str, code: str) -> None:
|
|
compiled = compile_formula(formula)
|
|
assert not compiled.ok
|
|
assert any(error.code == code for error in compiled.errors), [e.code for e in compiled.errors]
|
|
|
|
|
|
def test_depth_and_token_limits() -> None:
|
|
# AST 深度: 嵌套 13 层二元运算 (每层 bin 算 1) 超限
|
|
deep = "close"
|
|
for _ in range(13):
|
|
deep = f"({deep} + 1)"
|
|
compiled = compile_formula(deep)
|
|
assert not compiled.ok
|
|
assert any(error.code == "E006" for error in compiled.errors)
|
|
|
|
wide = " + ".join(["close"] * 120) # 二元链深度为 2, 但 token 超 200
|
|
compiled = compile_formula(wide)
|
|
assert any(error.code == "E007" for error in compiled.errors)
|
|
|
|
|
|
def test_error_payload_shape() -> None:
|
|
compiled = compile_formula("rank(ts_delta(close, -5))")
|
|
assert not compiled.ok
|
|
payload = compiled.errors[0].to_dict()
|
|
assert payload["code"] == "E005"
|
|
assert payload["message"]
|
|
assert "position" in payload and "offset" in payload["position"]
|
|
|
|
|
|
# ------------------------------------------------------------- 依赖与预热
|
|
|
|
def test_dependencies_and_warmup() -> None:
|
|
compiled = compile_formula("rank(-ts_sum(change_pct, 5))")
|
|
assert compiled.ok
|
|
# change_pct 是 base 因子, 依赖为其自身列
|
|
assert compiled.dependencies == frozenset({"change_pct"})
|
|
assert compiled.referenced_factors == frozenset({"change_pct"})
|
|
assert compiled.warmup_bars == 6 # ts 窗口 5 + 1
|
|
assert compiled.cross_sectional
|
|
|
|
compiled = compile_formula("close + ma20_bias")
|
|
assert compiled.ok
|
|
assert compiled.dependencies == frozenset({"close", "ma20"})
|
|
assert compiled.referenced_factors == frozenset({"ma20_bias"})
|
|
|
|
compiled = compile_formula("turnover_z_60d * 2")
|
|
assert compiled.ok
|
|
assert compiled.warmup_bars == 61 # 引用因子 warmup 传递
|
|
|
|
|
|
def test_base_columns_contract() -> None:
|
|
assert "close" in BASE_COLUMNS
|
|
assert "clos" not in BASE_COLUMNS
|
|
|
|
|
|
# ------------------------------------------------------------- 数值正确性
|
|
|
|
def test_ts_delay_backward_only() -> None:
|
|
values = _eval("ts_delay(close, 2)")
|
|
# A 组: [null, null, 10, 11, 12, 13]; B 组: [null, null, 100, 90, 80, 70]
|
|
assert values[:6].to_list() == [None, None, 10.0, 11.0, 12.0, 13.0]
|
|
assert values[6:].to_list() == [None, None, 100.0, 90.0, 80.0, 70.0]
|
|
|
|
|
|
def test_ts_mean_no_cross_symbol_leak() -> None:
|
|
values = _eval("ts_mean(close, 2)")
|
|
# A 组 2 日窗: [null, 10.5, 11.5, 12.5, 13.5, 14.5]
|
|
assert values[:6].to_list() == [None, 10.5, 11.5, 12.5, 13.5, 14.5]
|
|
# B 组边界: 第一行是 null (窗口不满) 而不是拿到 A 组尾部; 第二行 95.0
|
|
assert values[6] is None
|
|
assert values[7] == 95.0
|
|
|
|
|
|
def test_rank_cross_sectional() -> None:
|
|
values = _eval("rank(close)")
|
|
frame = _panel().with_columns(pl.Series("_f", values))
|
|
per_date = frame.filter(pl.col("date") == date(2026, 1, 1))
|
|
# 第一日 A=10, B=100: rank(A) < rank(B), 且都 ∈ (0,1]
|
|
ranks = dict(zip(per_date["symbol"].to_list(), per_date["_f"].to_list(), strict=True))
|
|
assert 0 < ranks["A"] < ranks["B"] <= 1.0
|
|
|
|
|
|
def test_cross_of_timeseries_two_phase() -> None:
|
|
# Polars 嵌套窗口会静默全 null; 编译器必须走两阶段 (临时列 + 截面)
|
|
values = _eval("rank(ts_sum(close, 2))")
|
|
assert sum(1 for value in values.to_list() if value is not None) > 0
|
|
# B 组滚动和数值恒大于 A 组 (百元 vs 十元价位) → 每个 (非首行) 日期 rank(B) > rank(A)
|
|
frame = _panel().with_columns(pl.Series("_f", values))
|
|
for day in (date(2026, 1, 3), date(2026, 1, 6)):
|
|
day_frame = frame.filter(pl.col("date") == day)
|
|
ranks = dict(zip(day_frame["symbol"].to_list(), day_frame["_f"].to_list(), strict=True))
|
|
assert ranks["B"] > ranks["A"]
|
|
|
|
|
|
def test_cross_in_timeseries_rejected() -> None:
|
|
compiled = compile_formula("ts_mean(rank(close), 5)")
|
|
assert not compiled.ok
|
|
assert any(error.code == "E009" for error in compiled.errors)
|
|
|
|
|
|
def test_safe_division_yields_null() -> None:
|
|
# 分母为动态表达式的恒 0: 静态折叠不报 E008, 运行期安全除产出 null
|
|
values = _eval("close / (close - close)")
|
|
assert all(value is None for value in values.to_list())
|
|
|
|
|
|
def test_if_else_and_comparison() -> None:
|
|
values = _eval("if_else(close > 12, 1, 0)")
|
|
assert values[:6].to_list() == [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
|
|
|
|
|
|
def test_arithmetic_precedence() -> None:
|
|
values = _eval("close + 2 * 3")
|
|
assert values[0] == 16.0 # 10 + 6, 而不是 (10+2)*3
|
|
|
|
|
|
def test_decay_linear_weights_recent() -> None:
|
|
values = _eval("decay_linear(close, 3)")
|
|
# A 组第 3 行: (3*12 + 2*11 + 1*10) / 6 = 68/6
|
|
assert values[2] == pytest.approx((3 * 12 + 2 * 11 + 1 * 10) / 6)
|
|
|
|
|
|
def test_log_domain_guard() -> None:
|
|
import math
|
|
|
|
panel = _panel().with_columns((pl.col("close") - 15.0).alias("neg"))
|
|
values = _eval("log(close - 15)", panel)
|
|
# A 组全为负 → null; B 组 100-15=85 → log 正常
|
|
assert all(value is None for value in values[:6].to_list())
|
|
assert values[6] == pytest.approx(math.log(85.0))
|
|
|
|
|
|
def test_ts_corr_two_series() -> None:
|
|
values = _eval("ts_corr(close, volume, 3)")
|
|
# 常数序列或完全单调: 只验证产出为有限值或 null, 无串组异常即可
|
|
assert len(values) == 12
|
|
|
|
|
|
# --------------------------------------------------------- 运行期 fail-closed
|
|
|
|
def test_runtime_missing_column_fails_closed() -> None:
|
|
compiled = compile_formula("close * volume")
|
|
assert compiled.ok
|
|
frame_without_volume = _panel().drop("volume")
|
|
assert compiled.frame_transform is not None
|
|
assert compiled.frame_transform(frame_without_volume) is None # E013 语义
|
|
assert compiled.frame_transform(_panel()) is not None
|