Files
tick-stock-panel/backend/tests/test_definition_contract.py
T
shy3130 e382232243 feat(definitions): 统一定义契约 P1b — Store 基类收口四处持久化 + kind 注册分发
- app/definitions.py 新增 JsonDefinitionStore: 原子写 + 损坏跳过告警 +
  目录 mtime 签名, glob/日志名/逐条归一化参数化; 自定义信号/自定义因子/
  监控规则/批次四处同型实现收口为委托, 公开签名零变化。
- DefinitionKind 注册表 + validate_definition / definition_dependencies
  统一分发入口; 内置 kind (signal/factor) 惰性导入, 消费方无需预导入领域
  模块。signal 依赖提取复用编译表达式 root_names (与回测/管线同源),
  factor 复用 to_spec DSL 编译 — 不引入第二套提取逻辑。
- 验收: 新增第三种 kind = 注册一个描述器即走同一存取/校验/依赖管线
  (test_new_kind_reuses_same_pipeline), 对应路线图 P1b 验收标准。
- 契约视图归一化渲染留给 P4 消费方; 失效广播/文件锁随 P2 叠加。

Signed-off-by: shy3130 <shy3130@qq.com>
2026-10-26 14:02:54 +08:00

253 lines
9.6 KiB
Python

"""统一定义契约 (P1b) — Store 基类 + kind 注册表的契约测试。
验收对象 (docs/ext-architecture-plan.md P1b):
- JsonDefinitionStore 收口四处同型持久化 (信号/因子/监控规则/批次);
- 新增一种 kind = 注册一个 DefinitionKind 描述器, 即复用同一
校验/注册/依赖提取路径, 不再平行实现持久化与分发。
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from app.definitions import (
DefinitionKind,
JsonDefinitionStore,
definition_dependencies,
get_kind,
register_kind,
registered_kinds,
validate_definition,
)
# ── Store 基类 ───────────────────────────────────────────
def test_store_roundtrip_and_delete(tmp_path: Path) -> None:
store = JsonDefinitionStore("user_data/things", label="thing")
store.save_one(tmp_path, {"id": "t1", "v": 1})
assert [d["id"] for d in store.load_all(tmp_path)] == ["t1"]
assert store.load_one(tmp_path, "t1") == {"id": "t1", "v": 1}
assert store.load_one(tmp_path, "missing") is None
assert store.delete_one(tmp_path, "t1") is True
assert store.delete_one(tmp_path, "t1") is False
assert store.load_all(tmp_path) == []
def test_store_corrupt_file_skipped_not_fatal(tmp_path: Path) -> None:
store = JsonDefinitionStore("user_data/things", label="thing")
store.save_one(tmp_path, {"id": "t1", "v": 1})
(tmp_path / "user_data" / "things" / "bad.json").write_text("{broken", encoding="utf-8")
assert [d["id"] for d in store.load_all(tmp_path)] == ["t1"]
def test_store_normalize_and_glob_pattern(tmp_path: Path) -> None:
store = JsonDefinitionStore(
"user_data/things",
glob_pattern="thing_*.json",
label="thing",
normalize=lambda d: {**d, "filled": True},
)
store.save_one(tmp_path, {"id": "thing_1"})
store.save_one(tmp_path, {"id": "other"}) # 不匹配 glob, 不进读取面
loaded = store.load_all(tmp_path)
assert [d["id"] for d in loaded] == ["thing_1"]
assert loaded[0]["filled"] is True
def test_store_fingerprint_tracks_changes(tmp_path: Path) -> None:
store = JsonDefinitionStore("user_data/things", label="thing")
fp0 = store.fingerprint(tmp_path)
store.save_one(tmp_path, {"id": "t1"})
fp1 = store.fingerprint(tmp_path)
assert fp1 != fp0
# mtime 精度内编辑也要能感知: 保存另一文件必然改变签名
store.save_one(tmp_path, {"id": "t2"})
assert store.fingerprint(tmp_path) != fp1
store.delete_one(tmp_path, "t1")
store.delete_one(tmp_path, "t2")
assert store.fingerprint(tmp_path) == ()
# ── 内置 kind: 校验与依赖提取走统一分发 ──────────────────
def test_builtin_kinds_registered() -> None:
kinds = registered_kinds()
assert "signal" in kinds
assert "factor" in kinds
def test_get_kind_lazy_imports_domain_module() -> None:
"""冷进程只导入 app.definitions 时, get_kind 惰性导入领域模块完成注册。
已导入模块不会重新执行 (import 缓存), 因此不能用"弹出再取"模拟,
必须以子进程冷启动验证。
"""
import subprocess
import sys
code = (
"from app.definitions import get_kind, registered_kinds;"
"assert 'signal' in registered_kinds();"
"assert get_kind('factor').kind == 'factor';"
"print('lazy-ok')"
)
result = subprocess.run(
[sys.executable, "-c", code], cwd=Path(__file__).resolve().parents[1],
capture_output=True, text=True, check=True,
)
assert "lazy-ok" in result.stdout
def test_signal_validate_dispatch(tmp_path: Path) -> None:
good = {
"id": "test_sig", "name": "t", "kind": "both", "timeframe": "daily",
"conditions": [{"left": "close", "op": ">", "right": "ma20"}],
}
validate_definition("signal", good) # 不抛
bad = dict(good, id="BAD")
with pytest.raises(ValueError, match="id 非法"):
validate_definition("signal", bad)
def test_signal_dependencies_reuse_compile_path() -> None:
"""信号依赖提取 = 编译表达式 root_names (与回测/管线同源)。
symbol 来自日期偏移的 .over("symbol") 分组列 — 与
pipeline.get_signal_dependencies 的既有口径一致。
"""
sig = {
"id": "test_sig", "name": "t", "kind": "both", "timeframe": "daily",
"conditions": [
{"left": "close", "op": ">", "right": "field:ma20", "rightDays": 1},
{"left": "rsi_14", "op": "<", "right": 30},
],
}
deps = definition_dependencies("signal", sig)
assert deps == frozenset({"close", "ma20", "rsi_14", "symbol"})
def test_signal_dependencies_intraday_branch() -> None:
sig = {
"id": "test_ia", "name": "t", "kind": "entry", "timeframe": "intraday",
"conditions": [{"left": "price", "op": ">", "right": 0}],
}
deps = definition_dependencies("signal", sig)
assert "price" in deps # 盘中分支走 build_intraday_expressions
def test_factor_dependencies_reuse_dsl_compile() -> None:
definition = {"id": "uf_x", "label": "x", "kind": "custom",
"formula": "close / ts_mean(turnover_rate, 20)"}
assert definition_dependencies("factor", definition) == frozenset({"close", "turnover_rate"})
with pytest.raises(ValueError, match="id 必须匹配"):
validate_definition("factor", {**definition, "id": "zz"})
def test_unknown_kind_rejected() -> None:
with pytest.raises(ValueError, match="未知 definition kind"):
validate_definition("nope", {})
def test_duplicate_kind_with_different_descriptor_rejected() -> None:
with pytest.raises(ValueError, match="描述器不同"):
register_kind(DefinitionKind(
kind="signal",
store=JsonDefinitionStore("user_data/x", label="x"),
id_pattern=re.compile(r"^x$"),
validate=lambda d: None,
))
# ── P1b 验收: 新增第三种 kind 复用同一管线 ────────────────
# 不写任何新的持久化/分发代码 — 只注册描述器, 存取/校验/依赖提取全部复用。
def test_new_kind_reuses_same_pipeline(tmp_path: Path) -> None:
store = JsonDefinitionStore("user_data/scoring_defs", label="scoring def")
seen: list[dict] = []
def _validate(defn: dict) -> None:
if not defn.get("weights"):
raise ValueError("评分定义 weights 不能为空")
def _deps(defn: dict) -> frozenset[str]:
seen.append(defn)
return frozenset(defn.get("weights", {}))
register_kind(DefinitionKind(
kind="scoring_test",
store=store,
id_pattern=re.compile(r"^sc_[a-z0-9_]{1,40}$"),
validate=_validate,
dependencies=_deps,
))
try:
# 存储: 同一 Store 基类
definition = {"id": "sc_vol", "weights": {"close": 0.6, "volume": 0.4}}
store.save_one(tmp_path, definition)
assert store.load_all(tmp_path) == [definition]
# 校验: 同一分发入口, 领域错误信息原样透出
validate_definition("scoring_test", definition)
with pytest.raises(ValueError, match="weights 不能为空"):
validate_definition("scoring_test", {"id": "sc_x", "weights": {}})
# 依赖提取: 同一分发入口
assert definition_dependencies("scoring_test", definition) == frozenset({"close", "volume"})
assert seen == [definition]
# 描述器可从注册表取回 (P4 统一选择器/表单的消费形态)
assert get_kind("scoring_test").store is store
finally:
import app.definitions as defs
defs._KINDS.pop("scoring_test", None)
def test_kind_without_extractor_returns_empty(tmp_path: Path) -> None:
"""无依赖提取器的 kind (纯记录类) → 空集, 不报错。"""
register_kind(DefinitionKind(
kind="record_test",
store=JsonDefinitionStore("user_data/records", label="record"),
id_pattern=re.compile(r"^r_"),
validate=lambda d: None,
))
try:
assert definition_dependencies("record_test", {"id": "r_1"}) == frozenset()
finally:
import app.definitions as defs
defs._KINDS.pop("record_test", None)
# ── 四处存量收口的回归锚点 (公开签名不变, 行为等价) ────────
def test_existing_modules_share_store_base(tmp_path: Path) -> None:
"""信号/因子/监控规则/批次的持久化函数全部委托统一 Store。"""
from app.factors import store as factor_store
from app.strategy import custom_signals, lots, monitor_rules
assert isinstance(custom_signals._STORE, JsonDefinitionStore)
assert isinstance(factor_store._STORE, JsonDefinitionStore)
assert isinstance(monitor_rules._RULE_STORE, JsonDefinitionStore)
assert isinstance(lots._LOT_STORE, JsonDefinitionStore)
# 批次语义: 非 lot_ 前缀文件不入读取面 (glob 参数化生效)
lots.save_one(tmp_path, {
"id": "lot_1", "symbol": "000001", "name": "x",
"buy_date": "2026-01-05", "qty": 100, "cost": 10.0, "target_pct": 20,
})
(tmp_path / "user_data" / "lots" / "stray.json").write_text(json.dumps({"id": "stray"}), encoding="utf-8")
assert [lot["id"] for lot in lots.load_all(tmp_path)] == ["lot_1"]
# 监控规则语义: 读取归一化默认值照常回填
monitor_rules.save_one(tmp_path, {
"id": "r1", "type": "price", "symbols": ["000001"],
"op": "above", "field": "close", "threshold": 10,
})
rule = monitor_rules.load_one(tmp_path, "r1")
assert rule["enabled"] is True and rule["scope"] == "symbols"