tradingSystem/scripts/test_batch29_units.py

399 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""三源合议 · 工作包二接入下单链路 (2026-09-11 方案第五节, 台账 006/009)。全部离线, 不连库。
工作包二的三块「大脑」(纯逻辑 / 装配服务 / 仓位矩阵) 由第二十八批守; 本批守它们**接进下单链路**
那一段, 也就是交接文档第五节的活。每一组都带一条「开关关掉逐字回旧」——这是「加不改」的机器证明。
A scan_open 合议分流: 跳过/观察不占名额、交人强制确认、放行照常、缺合议退回旧路、开关关掉回旧。
B 定档改用 advise_v2 与六键进硬数字, 且六键一个都不进 judge.OPEN_JUDGE_KEYS。
C 持仓增持门 consensus_gate_why 矩阵与 scan 端到端 (减持不受影响、开关关掉回旧)。
D 装配服务 _attach_consensus 与持仓 decorate_positions。
E 候选按质地排序 (质地看多分桶在前、技术面不参与、观察档进池开关、开关关掉回旧)。
F 三个新参数登记。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import action_engine as ae # noqa: E402
from app.services import judge as jd # noqa: E402
from app.services import plan_feed as pf # noqa: E402
from app.services import param_store as ps # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
# ================================================================ 夹具
def params(**kw):
p = {"scale": 2_000_000.0, "stock_target_default": 0.06,
"batch_split": (0.5, 0.25, 0.25), "min_lot_merge": True}
p.update(kw)
return p
def caps(**kw):
c = {"scale": 2_000_000.0, "portfolio_cap": 0.70, "stock_cap": 0.08,
"max_names": 20, "portfolio_mv": 0.0, "names_count": 0,
"stock_mv": 0.0, "is_new_name": False, "sector": None,
"sector_names": 0, "sector_mv": 0.0, "sector_names_map": {}, "sector_mv_map": {},
"sector_max_names": 4, "sector_max_ratio": 0.40, "cash_reserve": 0.0,
"sector_source_ready": True, "cash_avail": None, "cash_source": "estimate"}
c.update(kw)
return c
def cand(code, price=10.0, score=100.0, rank=1, **kw):
d = {"ts_code": code, "price": price, "score": score, "sector": kw.pop("sector", None),
"theme": kw.pop("theme", None), "tier": kw.pop("tier", "强传导"),
"upside": kw.pop("upside", 0.3), "heat": kw.pop("heat", 0.5),
"rank": rank, "bucket": "main", "src": "plan_api"}
d.update(kw)
return d
def with_con(c, route, *, fund="看多", tech="看多", timing="看多", reason=None):
"""给候选手挂一份合议 (直接控制路由, 不经装配服务) —— 单测 scan_open 分流本身。"""
c = dict(c)
con = {"route": route, "route_reason": reason or ("合议判" + route),
"direction": "看多", "votes": {"fund": fund, "tech": tech, "timing": timing},
"strength": "强", "reason": "三票 → 看多"}
c["consensus"] = con
c["consensus_blocks"] = {"fund": {"stance": fund}, "tech": {"stance": tech},
"timing": {"stance": timing}, "consensus": con}
c["consensus_hard"] = {"fund_stance": {"stance": fund}, "tech": {"stance": tech},
"tech_text": "技术面理由", "timing": {"stance": timing},
"consensus": con, "consensus_text": "合议理由"}
return c
PARAMS = {"scale": 2_000_000, "stock_target_default": 0.06,
"batch_split": (0.5, 0.25, 0.25), "cushion_solid": 0.03,
"trim_peak": 0.06, "trim_giveback": 0.5,
"dca_triggers": (-0.08, -0.15), "dca_deep_confirm": -0.15, "dca_max_ratio": 0.5,
"no_chase_ma5": 0.06, "build_window_tdays": 10, "fill_max_loss": -0.03}
def pos(**kw):
p = {"ts_code": "600000.SH", "price": 10.0, "avg_cost": 10.0, "total_qty": 6000,
"base_qty": 6000, "add_qty": 0, "dca_qty": 0, "market_value": 60_000,
"cushion_pct": 0.0, "cushion_peak": 0.0, "target_pct": 0.06,
"support_ref": None, "pressure_ref": None, "stop_ref": None,
"fill_count": 0, "dca_count": 0, "frozen_reason": "NONE"}
p.update(kw)
return p
def mkt(**kw):
m = {"ma5": 10.0, "high5": 10.0, "tdays_since_open": 3, "tdays_since_last_add": 5}
m.update(kw)
return m
def _blocks(direction="中性", tech="中性", fund="中性"):
return {"consensus": {"direction": direction}, "tech": {"stance": tech}, "fund": {"stance": fund}}
# ================================================================ A scan_open 合议分流
@case("A 分流·开关关掉逐字回旧: 挂了跳过的合议也照出候选, 与不挂时一字不差")
def _():
base = cand("600000.SH")
r_plain = ae.scan_open(candidates=[dict(base)], params=params(consensus_route=False),
caps=caps(), room_amt=1_400_000, slots=20)
r_con = ae.scan_open(candidates=[with_con(base, "跳过")], params=params(consensus_route=False),
caps=caps(), room_amt=1_400_000, slots=20)
assert r_plain == r_con, "关掉合议时, 挂不挂合议、路由是不是跳过, 结果必须一字不差"
assert len(r_plain["candidates"]) == 1
@case("A 分流·跳过: 不产候选、不占名额 (后面的票补上)")
def _():
cs = [with_con(cand("600000.SH", score=200), "跳过", reason="没有买方评析"),
with_con(cand("000001.SZ", score=100), "放行")]
r = ae.scan_open(candidates=cs, params=params(consensus_route=True),
caps=caps(), room_amt=1_400_000, slots=1)
assert [c["ts_code"] for c in r["candidates"]] == ["000001.SZ"], "跳过的票不该占掉唯一名额"
assert any(s["ts_code"] == "600000.SH" and "买方评析" in s["why"] for s in r["skipped"])
@case("A 分流·观察: 不产候选、带处置词 wait_tech")
def _():
r = ae.scan_open(candidates=[with_con(cand("600000.SH"), "观察", reason="等技术面开口")],
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
assert r["candidates"] == []
s = next(s for s in r["skipped"] if s["ts_code"] == "600000.SH")
assert s.get("disp") == ae.DISP_WAIT_TECH and "技术面" in s["why"]
@case("A 分流·交人: 照常产候选、占名额, 但强制人工确认并带原因")
def _():
r = ae.scan_open(candidates=[with_con(cand("600000.SH"), "交人", reason="基本面看空、技术面看多,试探仓")],
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
assert len(r["candidates"]) == 1
c = r["candidates"][0]
assert c.get("needs_user_confirm") is True and "试探" in (c.get("confirm_why") or "")
@case("A 分流·放行: 正常产候选, 不强制确认")
def _():
r = ae.scan_open(candidates=[with_con(cand("600000.SH"), "放行")],
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
assert len(r["candidates"]) == 1 and not r["candidates"][0].get("needs_user_confirm")
@case("A 分流·缺合议但开关开: 退回旧路照常产出 (装不上不拦)")
def _():
r = ae.scan_open(candidates=[cand("600000.SH")], params=params(consensus_route=True),
caps=caps(), room_amt=1_400_000, slots=20)
assert len(r["candidates"]) == 1
# ================================================================ B advise_v2 定档与六键
@case("B 定档·合议开着用 advise_v2 (矩阵, version=2); 关着用旧 advise (version=1)")
def _():
on = ae.scan_open(candidates=[with_con(cand("600000.SH"), "放行", fund="看多", tech="看多")],
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
adv_on = on["candidates"][0]["hard_numbers"]["advice"]
assert adv_on.get("version") == "2", "合议开着定档应改用 advise_v2"
off = ae.scan_open(candidates=[cand("600000.SH")], params=params(consensus_route=False),
caps=caps(), room_amt=1_400_000, slots=20)
assert off["candidates"][0]["hard_numbers"]["advice"].get("version") == "1"
@case("B 六键·合议开着六键进硬数字")
def _():
r = ae.scan_open(candidates=[with_con(cand("600000.SH"), "放行")],
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
hn = r["candidates"][0]["hard_numbers"]
for k in ("fund_stance", "tech", "tech_text", "timing", "consensus", "consensus_text"):
assert k in hn, f"六键 {k} 应进硬数字"
@case("B 六键·一个都不进 judge.OPEN_JUDGE_KEYS (基本面技术面不重判)")
def _():
six = {"fund_stance", "tech", "tech_text", "timing", "consensus", "consensus_text"}
assert six.isdisjoint(set(jd.OPEN_JUDGE_KEYS)), "六键混进送研判白名单会让决策系统重判基本面/技术面"
@case("B 六键·送研判裁剪后确实不含这六键 (机器证明)")
def _():
r = ae.scan_open(candidates=[with_con(cand("600000.SH"), "放行")],
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
sent = jd._judge_hard_numbers("OPEN", r["candidates"][0]["hard_numbers"])
for k in ("fund_stance", "tech", "tech_text", "timing", "consensus", "consensus_text"):
assert k not in sent
# ================================================================ C 持仓增持门 (纯逻辑矩阵)
@case("C 增持门·合议看空停三类 (回踩补足/盈利加仓/补仓)")
def _():
for act in (ae.A_FILL, ae.A_ADD, ae.A_DCA):
assert ae.consensus_gate_why(_blocks(direction="看空"), act), act
@case("C 增持门·技术面看空停回踩补足与盈利加仓, 不停补仓")
def _():
b = _blocks(direction="中性", tech="看空", fund="中性")
assert ae.consensus_gate_why(b, ae.A_FILL) and ae.consensus_gate_why(b, ae.A_ADD)
assert ae.consensus_gate_why(b, ae.A_DCA) is None
@case("C 增持门·基本面看空停补仓, 不停回踩补足与盈利加仓")
def _():
b = _blocks(direction="中性", tech="中性", fund="看空")
assert ae.consensus_gate_why(b, ae.A_DCA)
assert ae.consensus_gate_why(b, ae.A_FILL) is None and ae.consensus_gate_why(b, ae.A_ADD) is None
@case("C 增持门·三方非看空不设门; 缺块或无读数不设门 (弃权)")
def _():
b = _blocks(direction="看多", tech="看多", fund="看多")
for act in (ae.A_FILL, ae.A_ADD, ae.A_DCA):
assert ae.consensus_gate_why(b, act) is None
assert ae.consensus_gate_why(None, ae.A_FILL) is None
assert ae.consensus_gate_why({}, ae.A_DCA) is None
assert ae.consensus_gate_why(_blocks("无读数", "无读数", "无读数"), ae.A_DCA) is None
@case("C 增持门·scan 端到端: 合议看空真拦下 FILL")
def _():
p = pos(price=9.8, cushion_pct=-0.02, support_ref=9.7, market_value=58_800)
p["consensus_blocks"] = _blocks(direction="看空", tech="看空", fund="看空")
r = ae.scan(positions=[p], params={**PARAMS, "tech_gate_increase": True},
market={p["ts_code"]: mkt()})
assert not any(c["side"] == "buy" for c in r["candidates"]), "合议看空时不该出增持候选"
assert any(s["action"] == ae.A_FILL and "看空" in s["why"] for s in r["skipped"])
@case("C 增持门·scan 端到端: 减持侧 (保垫减仓) 不受合议看空影响")
def _():
p = pos(cushion_peak=0.08, cushion_pct=0.04)
p["consensus_blocks"] = _blocks(direction="看空", tech="看空", fund="看空")
r = ae.scan(positions=[p], params={**PARAMS, "tech_gate_increase": True},
market={p["ts_code"]: mkt()})
assert any(c["action"] == ae.A_TRIM for c in r["candidates"]), "减持不该被增持门拦"
@case("C 增持门·开关关掉逐字回旧: 挂了合议看空也照出 FILL")
def _():
base = pos(price=9.8, cushion_pct=-0.02, support_ref=9.7, market_value=58_800)
r_plain = ae.scan(positions=[dict(base)], params=PARAMS, market={base["ts_code"]: mkt()})
p2 = dict(base)
p2["consensus_blocks"] = _blocks(direction="看空", tech="看空", fund="看空")
r_con = ae.scan(positions=[p2], params=PARAMS, market={base["ts_code"]: mkt()}) # PARAMS 无 tech_gate_increase
assert [c["action"] for c in r_plain["candidates"]] == [c["action"] for c in r_con["candidates"]]
assert any(c["action"] == ae.A_FILL for c in r_con["candidates"])
# ================================================================ D 装配服务
_ASM_P = {"fund_required": True, "fund_stale_days": 120, "fund_consensus_good_min": 8,
"consensus_route": True, "timing_stale_tdays": 3}
def _cr(overall="好"):
return {"overall": overall, "valuation": "便宜",
"grade_counts": {"好": 8, "中": 2, "差": 0}, "groups": {}, "doubt_hard": False, "age_days": 0}
@case("D 装配·decorate_positions 给持仓挂 consensus_blocks, 评析从 company_reviews 带出")
def _():
from app.services import consensus_service as csv
rows = [{"ts_code": "600000.SH"}, {"ts_code": "000001.SZ"}]
csv.decorate_positions(rows, nightly={}, tech_states={},
company_reviews={"600000.SH": _cr("好")}, params=_ASM_P)
assert rows[0]["consensus_blocks"]["fund"]["stance"] == "看多"
assert rows[1]["consensus_blocks"]["fund"]["stance"] == "无读数" # 没带评析 = 无读数, 绝不折看空
@case("D 装配·decorate_positions 单行异常不塌 (缺 ts_code 抹掉键)")
def _():
from app.services import consensus_service as csv
rows = [{"ts_code": ""}]
csv.decorate_positions(rows, nightly={}, tech_states={}, company_reviews={}, params=_ASM_P)
assert "consensus_blocks" not in rows[0]
# ================================================================ E 候选按质地排序
def _row(code, score, overall=None, rank=1):
r = {"ts_code": code, "name": "股票", "score": score, "rank": rank, "tier": "强传导",
"bucket": "main"}
if overall:
r["company_review"] = {"overall": overall, "valuation": "便宜", "age_days": 0,
"grade_counts": {}, "groups": {}}
return r
@case("E 排序·开关关掉逐字回旧: 纯按分数降序")
def _():
plan = {"date": "2026-09-11", "main": [_row("600000.SH", 100), _row("000001.SZ", 90, "好")],
"observe": []}
out = pf.select_candidates(plan, rank_by_quality=False)
assert [x["ts_code"] for x in out["items"]] == ["600000.SH", "000001.SZ"]
@case("E 排序·按质地: 质地看多分桶在前, 低分看多排在高分无评析之前")
def _():
plan = {"date": "2026-09-11", "main": [_row("600000.SH", 100), _row("000001.SZ", 90, "好")],
"observe": []}
out = pf.select_candidates(plan, rank_by_quality=True)
assert [x["ts_code"] for x in out["items"]] == ["000001.SZ", "600000.SH"]
@case("E 排序·同桶内仍按分数 (质地差与无评析同桶, 高分在前)")
def _():
plan = {"date": "2026-09-11",
"main": [_row("600000.SH", 80, "差"), _row("000001.SZ", 90)], "observe": []}
out = pf.select_candidates(plan, rank_by_quality=True)
assert [x["ts_code"] for x in out["items"]] == ["000001.SZ", "600000.SH"]
@case("E 排序·观察档进池开关: 默认关不进, 开了质地看多的进")
def _():
plan = {"date": "2026-09-11", "main": [_row("600000.SH", 100)],
"observe": [_row("000001.SZ", 50, "好"), _row("000002.SZ", 60)]}
off = pf.select_candidates(plan, rank_by_quality=False, observe_if_fund_bull=False)
assert [x["ts_code"] for x in off["items"]] == ["600000.SH"]
on = pf.select_candidates(plan, rank_by_quality=False, observe_if_fund_bull=True)
codes = {x["ts_code"] for x in on["items"]}
assert "000001.SZ" in codes and "000002.SZ" not in codes # 只进质地看多的观察行
# ================================================================ F 参数登记
@case("F 参数·三个新开关登记进 RUNTIME_EXTRA, 类型与默认对")
def _():
for k in ("PMS_TECH_GATE_INCREASE", "PMS_PLAN_RANK_BY_QUALITY", "PMS_PLAN_OBSERVE_IF_FUND_BULL"):
assert k in ps.RUNTIME_EXTRA, k
assert ps.RUNTIME_EXTRA[k][1] is bool
assert ps.RUNTIME_EXTRA["PMS_TECH_GATE_INCREASE"][0] is True
assert ps.RUNTIME_EXTRA["PMS_PLAN_RANK_BY_QUALITY"][0] is True
assert ps.RUNTIME_EXTRA["PMS_PLAN_OBSERVE_IF_FUND_BULL"][0] is False # 守宁缺毋滥, 默认关
# ================================================================ G 上游名次排 (2026-09-17 工作包乙)
@case("G 上游名次排·rank_axis=quality 且开关开: 按上游名次升序、立场桶不参与、rank_mode=upstream")
def _():
# 高分好票名次靠后(rank=3)、低分无评析名次靠前(rank=1): upstream 按名次排 → 无评析在前, 证明立场桶不参与
plan = {"date": "2026-09-17", "rank_axis": "quality",
"main": [_row("600000.SH", 100, "好", rank=3), _row("000001.SZ", 60, rank=1)], "observe": []}
out = pf.select_candidates(plan, rank_by_upstream=True, rank_by_quality=True)
assert [x["ts_code"] for x in out["items"]] == ["000001.SZ", "600000.SH"]
assert out["rank_mode"] == "upstream"
@case("G 上游名次排·上游没带 rank_axis: 逐字回按分桶排 (rank_by_quality 生效, rank_mode=quality_bucket)")
def _():
plan = {"date": "2026-09-17", # 无 rank_axis 键 (旧计划或选股系统回退)
"main": [_row("600000.SH", 100, rank=1), _row("000001.SZ", 90, "好", rank=2)], "observe": []}
out = pf.select_candidates(plan, rank_by_upstream=True, rank_by_quality=True)
assert [x["ts_code"] for x in out["items"]] == ["000001.SZ", "600000.SH"] # 看多分桶在前, 与既有用例同口径
assert out["rank_mode"] == "quality_bucket"
@case("G 上游名次排·rank_mode 三态、四键透传、新开关登记")
def _():
q = {"date": "2026-09-17", "rank_axis": "quality", "main": [_row("600000.SH", 100, rank=1)], "observe": []}
assert pf.select_candidates(q, rank_by_upstream=True, rank_by_quality=True)["rank_mode"] == "upstream"
assert pf.select_candidates(q, rank_by_upstream=False, rank_by_quality=True)["rank_mode"] == "quality_bucket"
assert pf.select_candidates(q, rank_by_upstream=False, rank_by_quality=False)["rank_mode"] == "score"
p2 = {"date": "2026-09-17", "rank_axis": "quality", "observe": [],
"main": [{**_row("600000.SH", 100, rank=1),
"quality_band": 3, "pointed": True, "rank_axis": "quality", "rank_old": 5}]}
it = pf.select_candidates(p2, rank_by_upstream=True)["items"][0]
assert (it["quality_band"], it["pointed"], it["rank_axis"], it["rank_old"]) == (3, True, "quality", 5)
assert "PMS_PLAN_RANK_BY_UPSTREAM" in ps.RUNTIME_EXTRA
assert ps.RUNTIME_EXTRA["PMS_PLAN_RANK_BY_UPSTREAM"][1] is bool
assert ps.RUNTIME_EXTRA["PMS_PLAN_RANK_BY_UPSTREAM"][0] is True
def main():
from test_wiring import install_fakes
install_fakes() # 参数与仓储默认用内存,各用例再覆盖需要的依赖。
ok = 0
for name, fn in RESULTS:
try:
fn()
ok += 1
print(" ok " + name)
except Exception:
print(" FAIL " + name)
traceback.print_exc()
print("-" * 60)
if ok == len(RESULTS):
print("ALL PASS (%d cases)" % ok)
return 0
print("FAILED %d/%d" % (len(RESULTS) - ok, len(RESULTS)))
return 1
if __name__ == "__main__":
sys.exit(main())