tradingSystem/scripts/test_batch7_units.py

428 lines
18 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
第七批模块单测 (零外部依赖, 不连库不触网)
==========================================
运行: tradingSystem 仓库根目录执行 python scripts/test_batch7_units.py
覆盖: 上游选股计划接口 (plan_feed) 的解析新鲜度校验候选筛选三段纯逻辑
fixture 用的是 2026-07-30 上游给的真实应答样例 (截取前若干条, 结构一字未改),
坏数据用例另造 上游改了字段/漏了字段这类事故必须由单测先炸, 而不是盘中静默少票
"""
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import plan_feed as pf # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
def raises(exc, fn, *a, **kw):
try:
fn(*a, **kw)
except exc:
return True
except Exception as e:
raise AssertionError(f"期望 {exc.__name__}, 实际 {type(e).__name__}: {e}")
raise AssertionError(f"期望抛 {exc.__name__}, 但没抛")
# ================================================================ fixture
def _m(rank, code, name, score, theme, heat, upside, tier="强传导", n_sources=7, moved=0.0):
return {"rank": rank, "code": code, "name": name, "score": score,
"evidence": {"theme": theme, "n_sources": n_sources, "moved_ratio": moved},
"heat": heat, "upside": upside, "tier": tier}
def _o(rank, code, name, score, theme, heat, n_sources=9, moved=0.075):
return {"rank": rank, "code": code, "name": name, "score": score,
"evidence": {"theme": theme, "n_sources": n_sources, "moved_ratio": moved},
"heat": heat, "upside": None}
SAMPLE = {
"date": "2026-07-29",
"counts": {"main": 961, "observe": 107, "gate_covered": 2386},
"market_snapshot_days": ["2026-07-28"],
"heat_date": "2026-07-27",
"theme_cap": 5,
"main": [
_m(1, "SH600418", "江淮汽车", 242.24, "整车", 0.2249, 2.1203202525935945),
_m(2, "SH688717", "艾罗能源", 242.05, "储能", 0.4165, 1.5817543859649121),
_m(3, "SZ300952", "恒辉安防", 241.91, "传感器", 0.3391, 1.4061712846347607),
_m(4, "SH605598", "上海港湾", 241.72, "储能", 0.214, 1.292594091460947),
_m(6, "SH600875", "东方电气", 241.34, "整机", 0.4074, 1.2340787236824915,
n_sources=9, moved=0.075),
_m(10, "SH600104", "上汽集团", 241.18, "整车", 0.1239, 0.8065614360879017),
],
"observe": [
_o(1, "SH600877", "电科芯片", 101.18, "集成电路设计", 0.4743, moved=0.0),
_o(2, "SH603611", "诺力股份", 101.12, "整机", 0.5868),
_o(3, "SZ000768", "中航西飞", 101.06, "整机", 0.121),
],
"changes": None,
"encoding": "主榜分=200+传导档位×20+组内分(还没热、还便宜);观察档分=100+0.6z(传导)+0.4z(−热度)",
}
# ================================================================ 解析
@case("解析·真实样例: 计数/主题上限/热度日全部落位, 返回条数与 counts 分开记")
def _():
p = pf.parse_plan(SAMPLE)
assert p["date"] == "2026-07-29" and p["heat_date"] == "2026-07-27"
assert p["theme_cap"] == 5
assert p["counts"] == {"main": 961, "observe": 107, "gate_covered": 2386}
# counts 是上游全量, returned 是这次应答里真的给了几条 —— 混用会看不出上游截断了
assert p["returned"] == {"main": 6, "observe": 3}
assert p["market_snapshot_days"] == ["2026-07-28"]
assert p["encoding"].startswith("主榜分=200")
@case("解析·代码从前缀式归一为点式 (沪/深/科创/创业板四种都要对)")
def _():
p = pf.parse_plan(SAMPLE)
codes = [r["ts_code"] for r in p["main"]]
assert codes[:4] == ["600418.SH", "688717.SH", "300952.SZ", "605598.SH"], codes
assert [r["ts_code"] for r in p["observe"]][2] == "000768.SZ"
@case("解析·字段口径: score/heat/upside 转 float, evidence 拆平, bucket 打标")
def _():
p = pf.parse_plan(SAMPLE)
r = p["main"][0]
assert r["name"] == "江淮汽车" and r["rank"] == 1 and r["tier"] == "强传导"
assert abs(r["score"] - 242.24) < 1e-9 and abs(r["heat"] - 0.2249) < 1e-9
assert abs(r["upside"] - 2.1203202525935945) < 1e-12
assert r["theme"] == "整车" and r["n_sources"] == 7 and r["moved_ratio"] == 0.0
assert r["bucket"] == pf.BUCKET_MAIN and p["observe"][0]["bucket"] == pf.BUCKET_OBSERVE
# 观察档没有 tier、upside 是 null —— 都得是 None 而不是 0
assert p["observe"][0]["tier"] is None and p["observe"][0]["upside"] is None
@case("解析·themes 映射: 主榜优先于观察档, 同码不被观察档覆盖")
def _():
d = dict(SAMPLE)
d["observe"] = list(SAMPLE["observe"]) + [_o(9, "SH600418", "江淮汽车", 100.1, "汽车零部件", 0.2)]
p = pf.parse_plan(d)
assert p["themes"]["600418.SH"] == "整车", p["themes"]["600418.SH"]
assert p["themes"]["600877.SH"] == "集成电路设计"
@case("解析·变形应答一律抛 PlanFeedError (不是 JSON 对象 / 缺 date / 两档全空)")
def _():
raises(pf.PlanFeedError, pf.parse_plan, ["不是对象"])
raises(pf.PlanFeedError, pf.parse_plan, None)
raises(pf.PlanFeedError, pf.parse_plan, {"main": SAMPLE["main"]}) # 缺 date
raises(pf.PlanFeedError, pf.parse_plan, {"date": "2026-07-29", "main": [], "observe": []})
raises(pf.PlanFeedError, pf.parse_plan, {"date": " ", "main": SAMPLE["main"]})
@case("解析·脏行跳过而不整体失效: 非 dict / 缺 code / 同码重复 / evidence 非 dict")
def _():
d = {"date": "2026-07-29", "main": [
"我是脏行", None, 42,
{"rank": 1, "code": "SH600418", "score": 1.0, "evidence": "不是对象"},
{"rank": 2, "code": "", "score": 2.0},
{"rank": 3, "code": "600418", "score": 3.0}, # 与第一条同码 (归一后相同)
{"code": "SZ300952", "score": 4.0}, # 缺 rank
]}
p = pf.parse_plan(d)
codes = [r["ts_code"] for r in p["main"]]
assert codes == ["600418.SH", "300952.SZ"], codes
assert p["main"][0]["theme"] is None and p["main"][0]["n_sources"] is None
# rank 缺失用出现序号补 (第 7 个元素 → 7), 免得排序键出现 None
assert p["main"][1]["rank"] == 7, p["main"][1]["rank"]
assert p["counts"] == {"main": None, "observe": None, "gate_covered": None}
@case("解析·数值容错: 字符串数字可用, 空串/None/非数字一律 None 而不是 0")
def _():
d = {"date": "2026-07-29", "main": [
{"rank": "5", "code": "SH600418", "score": "242.24", "heat": "", "upside": None,
"evidence": {"theme": " 整车 ", "n_sources": "7", "moved_ratio": "abc"}}]}
r = pf.parse_plan(d)["main"][0]
assert r["rank"] == 5 and abs(r["score"] - 242.24) < 1e-9
assert r["heat"] is None and r["upside"] is None and r["moved_ratio"] is None
assert r["n_sources"] == 7 and r["theme"] == "整车" # theme 两头空白要去掉
# ================================================================ 新鲜度
@case("新鲜度·日龄: 当天 0 / 上一交易日 1 / 未来日期按 0 (为下一交易日出的计划)")
def _():
assert pf.plan_age_tdays("2026-07-30", today="2026-07-30") == 0
assert pf.plan_age_tdays("2026-07-29", today="2026-07-30") == 1
assert pf.plan_age_tdays("2026-07-27", today="2026-07-30") == 3
assert pf.plan_age_tdays("2026-07-31", today="2026-07-30") == 0
@case("新鲜度·跨周末只算交易日: 周五的计划到周一仍是 1 个交易日龄")
def _():
# 2026-07-24 周五, 2026-07-27 周一 —— 中间两天不是交易日, 不该被算进日龄
assert pf.plan_age_tdays("2026-07-24", today="2026-07-27") == 1
assert pf.plan_age_tdays("2026-07-24", today="2026-07-28") == 2
@case("新鲜度·超期抛错并说清日龄 (上游停更时拿旧榜当今天比没候选更危险)")
def _():
p = pf.parse_plan(SAMPLE) # date = 2026-07-29
assert pf.assert_fresh(p, max_stale_tdays=1, today="2026-07-30") == 1
assert pf.assert_fresh(p, max_stale_tdays=0, today="2026-07-29") == 0
raises(pf.PlanFeedError, pf.assert_fresh, p, max_stale_tdays=0, today="2026-07-30")
try:
pf.assert_fresh(p, max_stale_tdays=1, today="2026-08-05")
except pf.PlanFeedError as e:
assert "2026-07-29" in str(e) and "交易日" in str(e), str(e)
# ================================================================ 筛选
@case("筛选·按 score 降序取前 N, 同分按上游 rank 稳定次序")
def _():
p = pf.parse_plan(SAMPLE)
r = pf.select_candidates(p, top_n=3)
assert [x["ts_code"] for x in r["items"]] == ["600418.SH", "688717.SH", "300952.SZ"]
assert r["considered"] == 6 and r["eligible"] == 6 and r["dropped"]["capped"] == 3
# 同分: rank 小的在前
d = {"date": "2026-07-29", "main": [_m(9, "SH600001", "", 100.0, "整车", 0.1, 1.0),
_m(2, "SH600002", "", 100.0, "整车", 0.1, 1.0)]}
rr = pf.select_candidates(pf.parse_plan(d), top_n=2)
assert [x["ts_code"] for x in rr["items"]] == ["600002.SH", "600001.SH"]
@case("筛选·top_n=0 视为不截断 (别把'不限'写成'一只都不要')")
def _():
p = pf.parse_plan(SAMPLE)
assert len(pf.select_candidates(p, top_n=0)["items"]) == 6
assert len(pf.select_candidates(p, top_n=999)["items"]) == 6
@case("筛选·已持有与黑名单剔除, 且两者分开计数 (输入接受前缀式)")
def _():
p = pf.parse_plan(SAMPLE)
r = pf.select_candidates(p, held=["SH600418", "688717.SH"], black=["SZ300952"], top_n=10)
codes = [x["ts_code"] for x in r["items"]]
assert "600418.SH" not in codes and "688717.SH" not in codes and "300952.SZ" not in codes
assert r["dropped"]["held"] == 2 and r["dropped"]["black"] == 1
assert r["eligible"] == 3
@case("筛选·tier 白名单只对带 tier 的行生效; 观察档的闸门是 include_observe")
def _():
d = dict(SAMPLE)
d["main"] = list(SAMPLE["main"]) + [
_m(500, "SH600519", "弱票", 220.0, "白酒", 0.1, 0.5, tier="弱传导")]
p = pf.parse_plan(d)
r = pf.select_candidates(p, tiers=["强传导"], top_n=50)
assert "600519.SH" not in [x["ts_code"] for x in r["items"]]
assert r["dropped"]["tier"] == 1
# 观察档没有 tier —— tiers 非空时也不该被 tier 规则误杀, 但默认根本不进池
assert all(x["bucket"] == "main" for x in r["items"])
r2 = pf.select_candidates(p, tiers=["强传导"], include_observe=True, top_n=50)
obs = [x["ts_code"] for x in r2["items"] if x["bucket"] == "observe"]
assert obs == ["600877.SH", "603611.SH", "000768.SZ"], obs
assert r2["dropped"]["tier"] == 1
@case("筛选·跨档同码只留一次, 且留主榜那份 (主榜分高, 排序天然优先)")
def _():
d = dict(SAMPLE)
d["observe"] = list(SAMPLE["observe"]) + [_o(9, "SH600418", "江淮汽车", 100.1, "汽车零部件", 0.2)]
r = pf.select_candidates(pf.parse_plan(d), include_observe=True, top_n=50)
hit = [x for x in r["items"] if x["ts_code"] == "600418.SH"]
assert len(hit) == 1 and hit[0]["bucket"] == "main" and hit[0]["theme"] == "整车"
assert r["dropped"]["dup"] == 1
@case("筛选·score 下限与 n_sources 下限各自独立计数")
def _():
p = pf.parse_plan(SAMPLE)
r = pf.select_candidates(p, min_score=241.7, top_n=50)
assert [x["ts_code"] for x in r["items"]] == ["600418.SH", "688717.SH", "300952.SZ",
"605598.SH"]
assert r["dropped"]["score"] == 2
r2 = pf.select_candidates(p, min_sources=9, top_n=50)
assert [x["ts_code"] for x in r2["items"]] == ["600875.SH"], r2["items"]
assert r2["dropped"]["sources"] == 5
@case("筛选·upside 下限 (2.12=+212%): 对所有行生效, 观察档因 upside=null 被一并挡掉")
def _():
d = dict(SAMPLE)
d["observe"] = list(SAMPLE["observe"])
p = pf.parse_plan(d)
r = pf.select_candidates(p, min_upside=1.3, top_n=50)
assert [x["ts_code"] for x in r["items"]] == ["600418.SH", "688717.SH", "300952.SZ"], r["items"]
assert r["dropped"]["upside"] == 3 # 1.2926 / 1.2341 / 0.8066 三只在门槛下
# 观察档 upside 恒为 null → 按 0 算, 设了下限就全挡掉 (方向保守: 宁可少票)
r2 = pf.select_candidates(p, min_upside=1.3, include_observe=True, top_n=50)
assert all(x["bucket"] == "main" for x in r2["items"]), r2["items"]
assert r2["dropped"]["upside"] == 6
# 不设下限时观察档照旧能进 (闸门只有 include_observe)
r3 = pf.select_candidates(p, include_observe=True, top_n=50)
assert any(x["bucket"] == "observe" for x in r3["items"])
assert r3["dropped"]["upside"] == 0
@case("筛选·upside 永不参与排序 (券商目标价噪音大, 排序只认 score)")
def _():
d = {"date": "2026-07-29", "main": [
_m(1, "SH600001", "高分低空间", 242.0, "整车", 0.1, 0.30),
_m(2, "SH600002", "低分高空间", 200.0, "整车", 0.1, 5.00)]}
r = pf.select_candidates(pf.parse_plan(d), top_n=2)
assert [x["ts_code"] for x in r["items"]] == ["600001.SH", "600002.SH"], r["items"]
@case("筛选·输出字段: sector 用 theme 灌 (planner 吃这个), score 缺失兜 0.0 不留 None")
def _():
d = {"date": "2026-07-29", "main": [{"rank": 1, "code": "SH600418",
"evidence": {"theme": "整车"}}]}
x = pf.select_candidates(pf.parse_plan(d), top_n=5)["items"][0]
assert x["score"] == 0.0 and x["sector"] == "整车" and x["theme"] == "整车"
assert x["src"] == "plan_api" and x["ts_code"] == "600418.SH"
assert "price" not in x, "计划不带价格, 这里绝不能凭空造一个价出来"
@case("筛选·计数自洽: considered = eligible + 各项丢弃; items = eligible capped")
def _():
d = dict(SAMPLE)
d["main"] = list(SAMPLE["main"]) + [
_m(500, "SH600519", "弱票", 220.0, "白酒", 0.1, 0.5, tier="弱传导")]
p = pf.parse_plan(d)
r = pf.select_candidates(p, held=["SH600418"], black=["SZ300952"], tiers=["强传导"],
min_sources=7, top_n=2, include_observe=True)
dr = r["dropped"]
assert r["considered"] == 10, r["considered"]
assert r["considered"] == r["eligible"] + dr["held"] + dr["black"] + dr["tier"] \
+ dr["score"] + dr["sources"] + dr["upside"] + dr["dup"], (r, dr)
assert len(r["items"]) == r["eligible"] - dr["capped"] == 2
@case("筛选·空计划不炸 (main/observe 缺键) 且 date 透传")
def _():
r = pf.select_candidates({"date": "2026-07-29"}, top_n=5)
assert r["items"] == [] and r["considered"] == 0 and r["eligible"] == 0
assert r["date"] == "2026-07-29"
# ================================================================ 取数守卫
class _FakeResp:
def __init__(self, payload, status=200):
self._p, self.status_code = payload, status
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
def json(self):
if isinstance(self._p, Exception):
raise self._p
return self._p
def _with_fake_requests(handler):
"""把 requests 换成假模块 (装在 sys.modules 上, plan_feed 是函数内 import)。"""
import types
calls = []
fake = types.ModuleType("requests")
def _get(url, params=None, timeout=None):
calls.append({"url": url, "params": params, "timeout": timeout})
return handler(url, params, timeout)
fake.get = _get
prev = sys.modules.get("requests")
sys.modules["requests"] = fake
def restore():
if prev is None:
sys.modules.pop("requests", None)
else:
sys.modules["requests"] = prev
return calls, restore
@case("取数·base 为空立即抛 PlanFeedError, 一个 HTTP 请求都不发")
def _():
calls, restore = _with_fake_requests(
lambda *a: (_ for _ in ()).throw(AssertionError("不该发请求")))
try:
raises(pf.PlanFeedError, pf.fetch, base="", path="/plan", timeout=1)
raises(pf.PlanFeedError, pf.fetch, base=" ", path="/plan", timeout=1)
finally:
restore()
assert calls == [], calls
@case("取数·成功路径: URL 拼接/date 传参/超时透传, 并盖上 url 与 fetched_at")
def _():
calls, restore = _with_fake_requests(lambda *a: _FakeResp(SAMPLE))
try:
p = pf.fetch(base="http://192.168.16.155:8300/", path="plan", timeout=7,
date="2026-07-29")
finally:
restore()
assert calls[0]["url"] == "http://192.168.16.155:8300/plan", calls
assert calls[0]["params"] == {"date": "2026-07-29"} and calls[0]["timeout"] == 7
assert p["date"] == "2026-07-29" and p["requested_date"] == "2026-07-29"
assert p["url"].endswith("/plan") and p["fetched_at"] > 0
# 不传 date 时不能带一个 date=None 的空参上去
calls2, restore2 = _with_fake_requests(lambda *a: _FakeResp(SAMPLE))
try:
pf.fetch(base="http://x:8300", path="/plan", timeout=3)
finally:
restore2()
assert calls2[0]["params"] is None, calls2
@case("取数·HTTP 报错/JSON 坏掉一律包装成 PlanFeedError 并带上 URL")
def _():
for handler in (lambda *a: _FakeResp(SAMPLE, status=500),
lambda *a: _FakeResp(ValueError("Expecting value")),
lambda *a: (_ for _ in ()).throw(OSError("Connection refused"))):
calls, restore = _with_fake_requests(handler)
try:
try:
pf.fetch(base="http://192.168.16.155:8300", path="/plan", timeout=1)
raise AssertionError("应当抛 PlanFeedError")
except pf.PlanFeedError as e:
assert "192.168.16.155:8300/plan" in str(e), str(e)
finally:
restore()
@case("取数·来源常量与旧表来源都在: plan_api / buy_plan / both")
def _():
assert (pf.SRC_PLAN_API, pf.SRC_BUY_PLAN, pf.SRC_BOTH) == ("plan_api", "buy_plan", "both")
assert pf.FAIL_CACHE_SEC > 0, "失败也要缓存一会儿, 否则每分钟的调度位会把超时叠成雪崩"
def main():
import logging
logging.disable(logging.CRITICAL)
passed, failed = 0, 0
for name, fn in RESULTS:
try:
fn()
print(f" PASS {name}")
passed += 1
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
failed += 1
print("-" * 60)
if failed:
print(f"FAILED: {failed} / {passed + failed}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()