2026-07-30 16:21:16 +08:00
|
|
|
|
# -*- 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 两头空白要去掉
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 08:54:36 +08:00
|
|
|
|
@case("解析·漏斗: counts 是打分池, returned 是过筛后 —— 两者相减没有意义, 只做展示")
|
2026-07-30 17:00:15 +08:00
|
|
|
|
def _():
|
2026-07-31 08:54:36 +08:00
|
|
|
|
p = pf.parse_plan(SAMPLE)
|
|
|
|
|
|
assert p["funnel"] == {"scored_main": 961, "returned_main": 6,
|
|
|
|
|
|
"scored_observe": 107, "returned_observe": 3}, p["funnel"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("解析·吃满才叫截断: 拿 counts 判会永远报警 (961 vs 55 是两把尺子)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
# 上游签名默认 top=20 / obs_top=10。样例只有 6+3 条 —— 没吃满, 就是上游能给的全部,
|
|
|
|
|
|
# 尽管 counts 写着 961。**这正是不能拿 counts 判截断的原因。**
|
|
|
|
|
|
p = pf.parse_plan(SAMPLE)
|
|
|
|
|
|
assert p["requested"] == {"top": 20, "obs_top": 10, "theme_cap": None}, p["requested"]
|
|
|
|
|
|
assert p["truncated"] == {"main": False, "observe": False}, p["truncated"]
|
|
|
|
|
|
# 要了 6 条正好给 6 条 → 吃满, 可能还有更多
|
|
|
|
|
|
q = pf.parse_plan(SAMPLE, requested={"top": 6, "obs_top": 3, "theme_cap": 999})
|
|
|
|
|
|
assert q["truncated"] == {"main": True, "observe": True}, q["truncated"]
|
|
|
|
|
|
assert q["requested"]["theme_cap"] == 999
|
|
|
|
|
|
# 要 100 条只给 6 条 → 没吃满
|
|
|
|
|
|
r = pf.parse_plan(SAMPLE, requested={"top": 100, "obs_top": 100})
|
|
|
|
|
|
assert r["truncated"] == {"main": False, "observe": False}
|
|
|
|
|
|
# top=0 (不传) 时无从判断, 不许瞎报
|
|
|
|
|
|
z = pf.parse_plan(SAMPLE, requested={"top": 0, "obs_top": 0})
|
|
|
|
|
|
assert z["truncated"] == {"main": False, "observe": False}
|
2026-07-30 17:00:15 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("取数·附加查询串: 解析 / 合并进请求 / date 不可被覆盖 / 坏串忽略不炸")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
assert pf.parse_query_extra("limit=1000") == {"limit": "1000"}
|
|
|
|
|
|
assert pf.parse_query_extra("?limit=1000&offset=20") == {"limit": "1000", "offset": "20"}
|
|
|
|
|
|
assert pf.parse_query_extra("") == {} and pf.parse_query_extra(None) == {}
|
|
|
|
|
|
assert pf.parse_query_extra(" ") == {}
|
|
|
|
|
|
assert pf.parse_query_extra("这不是查询串") == {} # 解析不出 k=v → 空, 不抛
|
|
|
|
|
|
calls, restore = _with_fake_requests(lambda *a: _FakeResp(SAMPLE))
|
|
|
|
|
|
try:
|
|
|
|
|
|
pf.fetch(base="http://x:8300", path="/plan", timeout=3,
|
|
|
|
|
|
extra_params={"limit": "1000", "date": "被覆盖的"}, date="2026-07-29")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
restore()
|
|
|
|
|
|
# date 是 PMS 自己的语义, extra 里同名键不许赢
|
|
|
|
|
|
assert calls[0]["params"] == {"limit": "1000", "date": "2026-07-29"}, calls[0]["params"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:21:16 +08:00
|
|
|
|
# ================================================================ 新鲜度
|
|
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:38:24 +08:00
|
|
|
|
@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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 09:17:54 +08:00
|
|
|
|
@case("筛选·ST 剔除 (上游明确不剔, 这道闸归 PMS): ST/*ST/退市 都要挡, 名字缺失不误杀")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
assert pf.is_st("ST喜临门") and pf.is_st("*ST辉丰") and pf.is_st("S*ST前锋")
|
|
|
|
|
|
assert pf.is_st("SST华塑") and pf.is_st("*st 辉丰") # 大小写与空格都要吃掉
|
|
|
|
|
|
assert pf.is_st("退市海润") and pf.is_st("海润退") # 退市整理期两种写法
|
|
|
|
|
|
assert not pf.is_st("江淮汽车") and not pf.is_st("") and not pf.is_st(None)
|
|
|
|
|
|
# 「圣农发展」「深赛格」这类 S 开头的中文名不许误杀 (A 股名称都是中文, 拉丁 ST 前缀安全)
|
|
|
|
|
|
assert not pf.is_st("圣农发展") and not pf.is_st("深赛格") and not pf.is_st("三安光电")
|
|
|
|
|
|
d = {"date": "2026-07-29", "main": [
|
|
|
|
|
|
_m(1, "SH600001", "ST喜临门", 240.0, "整机", 0.1, 1.0),
|
|
|
|
|
|
_m(2, "SH600002", "*ST辉丰", 239.0, "整机", 0.1, 1.0),
|
|
|
|
|
|
_m(3, "SH600003", "江淮汽车", 238.0, "整车", 0.1, 1.0),
|
|
|
|
|
|
{"rank": 4, "code": "SH600004", "score": 237.0, "tier": "强传导",
|
|
|
|
|
|
"evidence": {"theme": "整车"}}]} # 没有 name
|
|
|
|
|
|
p = pf.parse_plan(d)
|
|
|
|
|
|
r = pf.select_candidates(p, top_n=10, exclude_st=True)
|
|
|
|
|
|
assert [x["ts_code"] for x in r["items"]] == ["600003.SH", "600004.SH"], r["items"]
|
|
|
|
|
|
assert r["dropped"]["st"] == 2
|
|
|
|
|
|
assert r["st_unknown"] == ["600004.SH"], r["st_unknown"] # 判不了要报出来, 不静默
|
|
|
|
|
|
assert len(pf.select_candidates(p, top_n=10, exclude_st=False)["items"]) == 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("筛选·白名单档位取全判据: 看见白名单之外的档位才算齐 (吃满 top 不等于漏了)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
strong = [_m(i, f"SH60{i:04d}", f"强{i}", 240 - i, "整车", 0.1, 1.0) for i in range(1, 4)]
|
|
|
|
|
|
weak = [_m(9, "SH609999", "弱", 220.0, "整车", 0.1, 1.0, tier="弱传导")]
|
|
|
|
|
|
# 只有强传导 → 判不出后面还有没有, tier_complete=False (被 top 卡住了)
|
|
|
|
|
|
only_strong = pf.select_candidates(pf.parse_plan({"date": "2026-07-29", "main": strong}),
|
|
|
|
|
|
tiers=["强传导"], top_n=10)
|
|
|
|
|
|
assert only_strong["tier_complete"] is False
|
|
|
|
|
|
# 出现弱传导 → 强传导已经全在前面了
|
|
|
|
|
|
both = pf.select_candidates(pf.parse_plan({"date": "2026-07-29", "main": strong + weak}),
|
|
|
|
|
|
tiers=["强传导"], top_n=10)
|
|
|
|
|
|
assert both["tier_complete"] is True
|
|
|
|
|
|
# 没设白名单时无从判断
|
|
|
|
|
|
assert pf.select_candidates(pf.parse_plan({"date": "2026-07-29", "main": strong}),
|
|
|
|
|
|
top_n=10)["tier_complete"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 08:54:36 +08:00
|
|
|
|
@case("筛选·候选级主题限额在 top_n 截断之前生效 (否则前 N 名被单一主题垄断)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
# 造一个"宽池子": 储能 6 只分最高, 传感器 3 只, 整车 2 只
|
|
|
|
|
|
rows = ([_m(i, f"SH60{i:04d}", f"储{i}", 300 - i, "储能", 0.2, 1.0) for i in range(1, 7)]
|
|
|
|
|
|
+ [_m(10 + i, f"SH61{i:04d}", f"传{i}", 200 - i, "传感器", 0.2, 1.0) for i in range(1, 4)]
|
|
|
|
|
|
+ [_m(20 + i, f"SH62{i:04d}", f"整{i}", 100 - i, "整车", 0.2, 1.0) for i in range(1, 3)])
|
|
|
|
|
|
p = pf.parse_plan({"date": "2026-07-29", "main": rows})
|
|
|
|
|
|
# 不限主题: 前 5 名全是储能 —— 规则闸按 PMS_SECTOR_MAX_NAMES 一拦就废掉大半
|
|
|
|
|
|
plain = pf.select_candidates(p, top_n=5)
|
|
|
|
|
|
assert {x["theme"] for x in plain["items"]} == {"储能"}, plain["items"]
|
|
|
|
|
|
# 限 2 只/主题: 先摊开再截断, 5 个名额分到三个主题
|
|
|
|
|
|
capped = pf.select_candidates(p, top_n=5, theme_cap=2)
|
|
|
|
|
|
got = [(x["theme"], x["ts_code"]) for x in capped["items"]]
|
|
|
|
|
|
assert [t for t, _ in got] == ["储能", "储能", "传感器", "传感器", "整车"], got
|
|
|
|
|
|
assert capped["dropped"]["theme"] == 5 # 储能多 4 只 + 传感器多 1 只
|
|
|
|
|
|
# 主题为空的行归到「(无主题)」一档, 不跟着别的主题挤
|
|
|
|
|
|
p2 = pf.parse_plan({"date": "2026-07-29", "main": [
|
|
|
|
|
|
{"rank": 1, "code": "SH600001", "score": 9}, {"rank": 2, "code": "SH600002", "score": 8}]})
|
|
|
|
|
|
assert len(pf.select_candidates(p2, top_n=5, theme_cap=1)["items"]) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("取数·build_query: 显式 top/obs_top/theme_cap 覆盖 QUERY_EXTRA 同名键; 0=不传")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
assert pf.build_query({"top": 300, "obs_top": 100, "theme_cap": 999, "query_extra": {}}) == \
|
|
|
|
|
|
{"top": "300", "obs_top": "100", "theme_cap": "999"}
|
|
|
|
|
|
# 0 = 不传该参数 (用上游默认)
|
|
|
|
|
|
assert pf.build_query({"top": 0, "obs_top": 0, "theme_cap": 0, "query_extra": {}}) == {}
|
|
|
|
|
|
# QUERY_EXTRA 里的同名键输给显式参数; 非同名键照旧透传
|
|
|
|
|
|
q = pf.build_query({"top": 300, "obs_top": 0, "theme_cap": 0,
|
|
|
|
|
|
"query_extra": {"top": "20", "foo": "bar"}})
|
|
|
|
|
|
assert q == {"top": "300", "foo": "bar"}, q
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:21:16 +08:00
|
|
|
|
@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=["强传导"],
|
2026-07-31 08:54:36 +08:00
|
|
|
|
min_sources=7, top_n=2, include_observe=True, theme_cap=2)
|
2026-07-30 16:21:16 +08:00
|
|
|
|
dr = r["dropped"]
|
|
|
|
|
|
assert r["considered"] == 10, r["considered"]
|
|
|
|
|
|
assert r["considered"] == r["eligible"] + dr["held"] + dr["black"] + dr["tier"] \
|
2026-07-31 08:54:36 +08:00
|
|
|
|
+ dr["score"] + dr["sources"] + dr["upside"] + dr["theme"] + dr["dup"], (r, dr)
|
2026-07-30 16:21:16 +08:00
|
|
|
|
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()
|