tradingSystem/scripts/test_batch22_units.py

563 lines
33 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 -*-
"""
第二十二批模块单测 (逻辑状态四态接入 PMS, 零外部依赖, 不连库不触网)
==================================================================
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch22_units.py
背景 (2026-09-07 下一阶段方案第三件): 选股系统每早给每只票算一个「支撑它的研究证据还在不在」的
状态 (逻辑强化 / 逻辑成立 / 无法判断 / 逻辑存疑), 随计划每行下发, 另有按代码查询的接口给持仓票用。
此前 PMS 收到了却没人读; 持仓不绑入场论点; 「逻辑不证伪不退出」没有判据。本批钉住:
* 解析层收逻辑状态, 硬数字带它, 但送研判的白名单不收 (一致性检查表第九行: 择时层不判产业逻辑);
* 判决候选而逻辑存疑的新建仓强制人工确认 (复用强制确认字段, 与关注判决同一条队列);
* 逻辑存疑的持仓停增持侧自主动作, 减持侧照评; 开关关着一行都不执行; 没有读数不拦;
* 研究走弱的减持: 默认关, 开了也必定交人 (research_weak 来源一票否决), 数量按可卖量夹紧;
* 同轮只发一条减持的优先级: 到价清仓 > 研究走弱 > 保垫减仓;
* 早上取回: 取不到写空映射带原因、绝不折成存疑、不动已有暂停; 映射超龄按没有读数;
* 策略买入腿按来源暂停与恢复, 不动风控与定性停的;
* 持仓视图两栏: 有指令链 / 外部成交并入 / 账本无行 三种情形都说得出话, 仓库读失败页面不塌。
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
"""
import os
import sys
import traceback
from datetime import date, datetime, timedelta
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE))
sys.path.insert(0, _HERE)
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 logic_state_service as lss # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
NOW = datetime(2026, 9, 8, 10, 30)
CODE = "600000.SH"
TODAY = date.today().isoformat()
# ================================================================ 夹具
class _Patch:
"""临时替换若干模块属性, 退出时原样还回去 —— 单测不连库不触网。"""
def __init__(self):
self.saved = []
def __call__(self, mod, name, value):
self.saved.append((mod, name, getattr(mod, name)))
setattr(mod, name, value)
def __enter__(self):
return self
def __exit__(self, *a):
for mod, name, old in reversed(self.saved):
setattr(mod, name, old)
return False
def _patch_params(p, mod, values):
v = values
def _get_list(k, d=None, sep=","):
x = v.get(k)
if x in (None, ""):
return list(d or [])
return [s.strip() for s in str(x).split(sep) if s.strip()]
p(mod, "get", lambda k, d=None: v.get(k, d))
p(mod, "get_int", lambda k, d=0: int(v.get(k, d)))
p(mod, "get_float", lambda k, d=0.0: float(v.get(k, d)))
p(mod, "get_bool", lambda k, d=False: bool(v.get(k, d)))
p(mod, "get_list", _get_list)
def doubt(as_of="2026-09-04"):
return {"state": "逻辑存疑", "raw_state": "逻辑存疑", "why": None, "settle_note": "进入逻辑存疑即刻成立",
"as_of": as_of, "usable": ["券商行动"], "missing": ["研报论断", "产业研判", "公司事件"],
"reasons": ["券商行动的转弱达到进入条件:每股收益预测下修且覆盖机构收缩",
"券商行动2026-09-04每股收益预测中位数下修 40%,同时覆盖机构从 5 家收缩到 3 家"]}
def hold():
return {"state": "逻辑成立", "raw_state": "逻辑成立", "why": None, "settle_note": "维持",
"as_of": "2026-09-04", "usable": ["研报论断"], "missing": ["产业研判", "券商行动", "公司事件"],
"reasons": ["研报论断2026-08-25最近一条利好"]}
def pos(code=CODE, **kw):
p = {"ts_code": code, "total_qty": 3000, "avail_qty": 3000, "price": 10.0, "price_ok": True,
"frozen_reason": "NONE", "cushion_pct": 0.02, "cushion_peak": 0.03, "avg_cost": 9.8}
p.update(kw)
return p
SCAN_PARAMS = {"trim_peak": 0.06, "trim_giveback": 0.5, "logic_state_route": True,
"logic_doubt_trim": False, "logic_doubt_trim_ratio": 1.0 / 3, "scale": 1_000_000}
PARAMS_BASE = {"PMS_EXEC_WINDOW_TDAYS": 3, "PMS_PROPOSAL_TTL_HOURS": 24,
"PMS_JUDGE_TICK_BUDGET_SEC": 150}
JUDGE_PASS = {"verdict": "PASS", "reason": "理由仍成立", "degraded": False,
"raw": {"verdict": "PASS"}, "confidence": 70.0}
def _route(c, *, autonomy="propose_only", judge_resp=JUDGE_PASS, dry_run=False, values=None,
price=10.0):
"""把一条候选送进 proposal_service._route_one, 规则闸/研判/落表全换成桩 (口径同第二十一批)。"""
from app.core import rule_gate
from app.repo import pms_repo
from app.services import judge, param_store, portfolio, proposal_service as psvc
got = {"ledger": [], "instructions": [], "proposals": [], "judge_calls": []}
with _Patch() as p:
p(rule_gate, "check", lambda **kw: {"passed": True, "failed": [], "warnings": []})
p(judge, "request", lambda cand_, context=None, **kw: (
got["judge_calls"].append(cand_.get("action")) or dict(judge_resp)))
p(portfolio, "caps_ctx", lambda *a, **kw: {})
p(pms_repo, "insert_ledger", lambda **kw: got["ledger"].append(kw) or 1)
p(pms_repo, "insert_instruction", lambda **kw: got["instructions"].append(kw) or 1)
p(pms_repo, "insert_proposal", lambda **kw: got["proposals"].append(kw) or 1)
p(pms_repo, "list_ledger", lambda **kw: [])
p(pms_repo, "update_position", lambda code, **kw: 1)
_patch_params(p, param_store, {**PARAMS_BASE, **(values or {})})
out = {"autonomy": autonomy, "open_autonomy": autonomy, "executed": [], "queued": [],
"rejected": [], "skipped": [], "errors": [], "degraded": False}
held = [{"ts_code": c["ts_code"], "total_qty": 3000, "avail_qty": 3000,
"price": price, "frozen_reason": "NONE"}]
view = {"positions": held, "held": held, "sector_ready": False,
"params": {}, "totals": {}}
psvc._route_one(c, view, {"_mkt": {}}, {}, False, NOW, dry_run, out)
return out, got
# ================================================================ 一, 解析层与硬数字
@case("解析层·逻辑状态按约定的键归一, 出处最多三条; 缺键 / 非字典 / 没有 state 一律 None")
def _():
raw = {"state": "逻辑存疑", "raw_state": "逻辑存疑", "why": None, "settle_note": "进入逻辑存疑即刻成立",
"as_of": "2026-09-04", "usable": ["券商行动"], "missing": ["研报论断", "公司事件"],
"reasons": ["a", "b", "c", "d"], "paths": [{"path": "x"}], "prev_state": "逻辑成立"}
st = pf._logic_state_or_none(raw)
assert st["state"] == "逻辑存疑" and st["as_of"] == "2026-09-04", st
assert st["reasons"] == ["a", "b", "c"] and st["usable"] == ["券商行动"], st
assert "paths" not in st and "prev_state" not in st, st # 只收约定的键
assert pf._logic_state_or_none(None) is None and pf._logic_state_or_none("逻辑存疑") is None
assert pf._logic_state_or_none({"why": "x"}) is None
# 没有原始态的旧形状: 原始态退回落定态
assert pf._logic_state_or_none({"state": "逻辑成立"})["raw_state"] == "逻辑成立"
row = pf._rows([{"code": "600000.SH", "rank": 1, "score": 210, "logic_state": raw}], "main")[0]
assert row["logic_state"]["state"] == "逻辑存疑", row
row2 = pf._rows([{"code": "600000.SH", "rank": 1, "score": 210}], "main")[0]
assert row2["logic_state"] is None, row2
@case("解析层·按代码查询接口的应答映射回 PMS 的代码形态, 带 error 的行跳过, source 与日期带上")
def _():
payload = {"date": "2026-09-04", "count": 3, "states": [
{"input": "600000.SH", "code": "SH600000", "source": "daily", **doubt()},
{"input": "300750", "code": "SZ300750", "source": "computed", **hold()},
{"input": "abc", "error": "认不出的代码形态"}]}
m = pf.parse_logic_states(payload)
assert set(m) == {"600000.SH", "300750.SZ"}, m
assert m["600000.SH"]["state"] == "逻辑存疑" and m["600000.SH"]["source"] == "daily"
assert m["300750.SZ"]["source"] == "computed" and m["300750.SZ"]["date"] == "2026-09-04"
assert pf.parse_logic_states(None) == {} and pf.parse_logic_states({"states": "x"}) == {}
assert pf.fetch_logic_states([]) == {} # 没票不触网
@case("候选筛选·items 带 logic_state (缺就是 None), 不改资格也不改次序")
def _():
plan = {"date": "2026-09-04", "main": pf._rows([
{"code": "600000.SH", "rank": 1, "score": 220, "verdict": "候选", "logic_state": doubt()},
{"code": "300750.SZ", "rank": 2, "score": 210, "verdict": "候选"}], "main"), "observe": []}
sel = pf.select_candidates(plan, top_n=10, route_by_verdict=True)
items = sel["items"]
assert [x["ts_code"] for x in items] == ["600000.SH", "300750.SZ"], items
assert items[0]["logic_state"]["state"] == "逻辑存疑" and items[1]["logic_state"] is None
@case("硬数字带逻辑状态, 而送研判的白名单不收它 (择时层不判产业逻辑, 检查表第九行)")
def _():
c = {"ts_code": CODE, "price": 10.0, "score": 220, "rank": 1, "bucket": "main", "src": "plan_api",
"verdict": "候选", "basis": "三门槛全过", "logic": ["研报说好"], "logic_state": doubt()}
params = {"scale": 1_000_000, "stock_target_default": 0.06, "batch_split": (0.5, 0.25, 0.25)}
with _Patch() as p:
p(ae, "check_all_caps", lambda **kw: [])
p(ae, "_new_name_ctx", lambda caps, c: caps)
cand, why = ae.eval_open(c, params, {"names_count": 0, "max_names": 10}, 500_000)
assert cand and not why, why
hn = cand["hard_numbers"]
assert hn["logic_state"]["state"] == "逻辑存疑" and hn["basis"] == "三门槛全过", hn
sent = jd._judge_hard_numbers("OPEN", hn)
assert "logic_state" not in sent and "basis" not in sent and "verdict" in sent, sent
assert "logic_state" not in jd.OPEN_JUDGE_KEYS
@case("安全边际整句·解析层收 valuation_text, 候选与硬数字带它, 研判白名单不收 (第四件, 只给人看)")
def _():
txt = "安全边际2026 年度预测9 家):悲观 42.64 元(-15.0%)、中性 56.75 元(+13.1%);赔率 0.87 比 1"
row = pf._rows([{"code": "600000.SH", "rank": 1, "score": 210, "valuation_text": txt}], "main")[0]
assert row["valuation_text"] == txt, row
assert pf._rows([{"code": "600000.SH", "rank": 1, "score": 210, "valuation_text": ""}], "main")[0]["valuation_text"] is None
plan = {"date": "2026-09-04", "main": [row], "observe": []}
item = pf.select_candidates(plan, top_n=10)["items"][0]
assert item["valuation_text"] == txt, item
c = {**item, "price": 10.0}
params = {"scale": 1_000_000, "stock_target_default": 0.06, "batch_split": (0.5, 0.25, 0.25)}
with _Patch() as p:
p(ae, "check_all_caps", lambda **kw: [])
p(ae, "_new_name_ctx", lambda caps, c: caps)
cand, why = ae.eval_open(c, params, {"names_count": 0, "max_names": 10}, 500_000)
assert cand and cand["hard_numbers"]["valuation_text"] == txt, why
assert "valuation_text" not in jd._judge_hard_numbers("OPEN", cand["hard_numbers"])
assert "valuation_text" not in jd.OPEN_JUDGE_KEYS
# ================================================================ 二, 新建仓与持仓的分流
def _scan_open(cands, *, route_logic=True):
params = {"scale": 1_000_000, "stock_target_default": 0.06, "batch_split": (0.5, 0.25, 0.25),
"open_route_by_verdict": True, "open_route_by_logic": route_logic,
"open_signal_priority": False}
with _Patch() as p:
p(ae, "check_all_caps", lambda **kw: [])
p(ae, "_new_name_ctx", lambda caps, c: caps)
p(ae, "_ctx_after", lambda ctx, *a, **kw: ctx)
return ae.scan_open(candidates=cands, params=params,
caps={"names_count": 0, "max_names": 10}, room_amt=900_000, slots=5)
@case("新建仓·判决候选而逻辑存疑 → 强制人工确认并写明出处; 关注保留自己的原因; 成立不强制; 开关关不强制")
def _():
base = {"price": 10.0, "score": 220, "rank": 1, "bucket": "main", "src": "plan_api"}
cands = [{**base, "ts_code": "600000.SH", "verdict": "候选", "logic_state": doubt()},
{**base, "ts_code": "600001.SH", "verdict": "关注", "logic_state": doubt()},
{**base, "ts_code": "600002.SH", "verdict": "候选", "logic_state": hold()},
{**base, "ts_code": "600003.SH", "verdict": "候选"}]
r = _scan_open(cands)
by = {c["ts_code"]: c for c in r["candidates"]}
assert set(by) == {"600000.SH", "600001.SH", "600002.SH", "600003.SH"}, r["skipped"]
a = by["600000.SH"]
assert a["needs_user_confirm"] and "逻辑存疑" in a["confirm_why"] and "证据截止 2026-09-04" in a["confirm_why"], a
assert "每股收益预测下修" in a["confirm_why"], a["confirm_why"]
b = by["600001.SH"]
assert b["needs_user_confirm"] and b["confirm_why"] == ae.WHY_WATCH_CONFIRM, b
assert not by["600002.SH"].get("needs_user_confirm") and not by["600003.SH"].get("needs_user_confirm")
r2 = _scan_open(cands, route_logic=False)
by2 = {c["ts_code"]: c for c in r2["candidates"]}
assert not by2["600000.SH"].get("needs_user_confirm"), by2["600000.SH"]
assert ae.logic_confirm_why(hold()) is None and ae.logic_confirm_why(None) is None
@case("持仓·逻辑存疑停增持侧 (补足/加仓/补仓写明原因), 减持侧照评; 开关关不停; 没有读数不停")
def _():
p = pos(logic_state=doubt(), cushion_peak=0.10, cushion_pct=0.04) # 保垫回吐过半 → TRIM 照评
r = ae.scan(positions=[p], params=SCAN_PARAMS, market={CODE: {}}, skip={})
acts = {c["action"] for c in r["candidates"]}
assert acts == {"TRIM"}, r["candidates"]
stopped = {s["action"]: s["why"] for s in r["skipped"] if "逻辑存疑" in s["why"]}
assert set(stopped) == {"ADD", "FILL", "DCA"}, r["skipped"]
assert all("停掉增持侧" in w and "证据截止 2026-09-04" in w for w in stopped.values()), stopped
r2 = ae.scan(positions=[pos(logic_state=doubt())], params={**SCAN_PARAMS, "logic_state_route": False},
market={CODE: {}}, skip={})
assert not [s for s in r2["skipped"] if "逻辑存疑" in s["why"]], r2["skipped"]
r3 = ae.scan(positions=[pos()], params=SCAN_PARAMS, market={CODE: {}}, skip={})
assert not [s for s in r3["skipped"] if "逻辑存疑" in s["why"]], r3["skipped"]
@case("研究走弱的减持·默认关不产出; 开了按三分之一取整并按可卖量夹紧, 来源 research_weak 且强制确认; 不足一手不产出")
def _():
p = pos(logic_state=doubt())
assert ae.eval_weak_research(p, SCAN_PARAMS) is None # 开关关
on = {**SCAN_PARAMS, "logic_doubt_trim": True}
c = ae.eval_weak_research(p, on)
assert c and c["action"] == "TRIM" and c["side"] == "sell" and c["qty"] == 1000, c
assert c["source"] == ae.SRC_RESEARCH_WEAK and c["needs_user_confirm"] and c["confirm_why"], c
assert "拟减 1000 股" in c["reason"] and "证据截止 2026-09-04" in c["reason"], c["reason"]
assert c["hard_numbers"]["logic_state"]["state"] == "逻辑存疑" and c["hard_numbers"]["trim_ratio"] == 0.3333
c2 = ae.eval_weak_research(pos(logic_state=doubt(), avail_qty=400), on)
assert c2["qty"] == 400, c2 # 夹到可卖量
assert ae.eval_weak_research(pos(logic_state=doubt(), total_qty=200, avail_qty=200), on) is None
assert ae.eval_weak_research(pos(logic_state=hold()), on) is None
assert ae.eval_weak_research(pos(), on) is None # 没有读数
c3 = ae.eval_weak_research(p, {**on, "logic_doubt_trim_ratio": 0.5})
assert c3["qty"] == 1500, c3
@case("同轮只发一条减持·优先级 到价清仓 > 研究走弱 > 保垫减仓, 让路的记进跳过原因")
def _():
on = {**SCAN_PARAMS, "logic_doubt_trim": True}
p = pos(logic_state=doubt(), cushion_peak=0.10, cushion_pct=0.04)
r = ae.scan(positions=[p], params=on, market={CODE: {}}, skip={},
stock_params={CODE: {"target_price": 9.5}})
assert [c["action"] for c in r["candidates"]] == ["EXIT"], r["candidates"]
yielded = [s for s in r["skipped"] if "一轮只发一条减持" in s["why"]]
assert len(yielded) == 2 and all(s["action"] == "TRIM" for s in yielded), yielded
r2 = ae.scan(positions=[p], params=on, market={CODE: {}}, skip={})
assert len(r2["candidates"]) == 1 and r2["candidates"][0]["source"] == ae.SRC_RESEARCH_WEAK, r2
assert ae._sell_priority({"action": "EXIT"}) < ae._sell_priority({"action": "TRIM", "source": "research_weak"}) \
< ae._sell_priority({"action": "TRIM", "source": "engine"})
@case("分流·研究走弱的减持在 full 档也入人工队列, 不落指令, 提议带来源与逻辑状态")
def _():
c = ae.eval_weak_research(pos(logic_state=doubt()), {**SCAN_PARAMS, "logic_doubt_trim": True})
for autonomy in ("propose_only", "full"):
out, got = _route(c, autonomy=autonomy)
assert not out["executed"] and len(out["queued"]) == 1, (autonomy, out)
assert "研究证据走弱" in out["queued"][0]["why"], out["queued"]
assert not got["instructions"] and len(got["proposals"]) == 1, (autonomy, got)
hn = got["proposals"][0]["hard_numbers"]
assert hn["source"] == ae.SRC_RESEARCH_WEAK and hn["logic_state"]["state"] == "逻辑存疑", hn
# ================================================================ 三, 早上取回与策略买入腿
@case("映射·超过三个自然日没刷新按没有读数; 时刻缺失或坏了也按没有读数; attach 只给有读数的行挂键")
def _():
fresh = {"at": f"{TODAY} 08:41:00", "states": {CODE: doubt()}}
assert lss.state_map(fresh) == {CODE: doubt()}
old = {"at": (date.today() - timedelta(days=5)).isoformat() + " 08:41:00", "states": {CODE: doubt()}}
assert lss.state_map(old) == {}
assert lss.state_map({"states": {CODE: doubt()}}) == {} and lss.state_map({}) == {}
rows = [pos(), pos("300750.SZ", logic_state="陈旧的")]
lss.attach(rows, {CODE: doubt()})
assert rows[0]["logic_state"]["state"] == "逻辑存疑" and "logic_state" not in rows[1], rows
@case("策略买入腿·存疑按来源 logic 暂停; 明确不存疑只清本来源; 没读数不动; 开关关整段跳过")
def _():
from app.services import param_store, strategy_service
calls = {"pause": [], "clear": []}
with _Patch() as p:
p(strategy_service, "pause_buy", lambda code, *, reason="", source="signal": (
calls["pause"].append((code, source, reason)) or ["S1"]))
p(strategy_service, "clear_buypause", lambda code, only_source=None: (
calls["clear"].append((code, only_source)) or {"ok": True, "cleared": code == "600001.SH"}))
_patch_params(p, param_store, {"PMS_LOGIC_STATE_ROUTE": True})
r = lss.apply_pauses({CODE: doubt(), "600001.SH": hold(), "600002.SH": hold()},
[CODE, "600001.SH", "600002.SH", "600003.SH"])
assert r["paused"] == [CODE] and r["resumed"] == ["600001.SH"] and not r["errors"], r
assert calls["pause"] == [(CODE, "logic", calls["pause"][0][2])] and "停掉增持侧" in calls["pause"][0][2]
assert [c for c, _ in calls["clear"]] == ["600001.SH", "600002.SH"], calls # 没读数的 600003 不动
assert all(s == "logic" for _, s in calls["clear"]), calls
with _Patch() as p:
p(strategy_service, "pause_buy", lambda *a, **k: (_ for _ in ()).throw(AssertionError("不该调")))
p(strategy_service, "clear_buypause", lambda *a, **k: (_ for _ in ()).throw(AssertionError("不该调")))
_patch_params(p, param_store, {"PMS_LOGIC_STATE_ROUTE": False})
r2 = lss.apply_pauses({CODE: doubt()}, [CODE])
assert r2["paused"] == [] and r2.get("skipped"), r2
@case("早上取回·查不到写空映射带原因且不动暂停 (绝不折成存疑); 查到写映射并按结果暂停")
def _():
from app.repo import pms_repo
from app.services import param_store, strategy_service
saved, calls = {}, {"pause": [], "clear": []}
with _Patch() as p:
p(pms_repo, "list_positions", lambda *, only_open=False: [{"ts_code": CODE}, {"ts_code": "600001.SH"}])
p(param_store, "set_param", lambda k, v, by="user": saved.__setitem__(k, v) or {"ok": True})
_patch_params(p, param_store, {"PMS_LOGIC_STATE_ROUTE": True})
p(strategy_service, "pause_buy", lambda code, *, reason="", source="signal": calls["pause"].append(code) or [])
p(strategy_service, "clear_buypause", lambda code, only_source=None: calls["clear"].append(code) or {"ok": True, "cleared": False})
def _boom(codes):
raise pf.PlanFeedError("选股系统连不上")
r = lss.pull_for_held(now=NOW, fetch=_boom)
import json
m = json.loads(saved[lss.MAP_KEY])
assert r["ok"] is False and m["states"] == {} and "连不上" in m["error"], (r, m)
assert not calls["pause"] and not calls["clear"], calls
r2 = lss.pull_for_held(now=NOW, fetch=lambda codes: {CODE: {**doubt(), "date": "2026-09-04"}})
m2 = json.loads(saved[lss.MAP_KEY])
assert r2["ok"] and r2["got"] == 1 and r2["by_state"] == {"逻辑存疑": 1}, r2
assert m2["date"] == "2026-09-04" and m2["states"][CODE]["state"] == "逻辑存疑", m2
assert r2["paused"] == [CODE] and r2["missing"] == ["600001.SH"], r2 # 没查到的票记明, 不动
# ================================================================ 四, 持仓视图两栏
def _fake_repo():
from test_wiring import FakeRepo
f = FakeRepo()
f.insert_instruction(instruction_id="I1", origin_type="proposal", origin_id="P1", ts_code=CODE,
action="OPEN", side="buy", qty=1000)
f.insert_ledger(ts_code=CODE, action="OPEN", arbiter="user", verdict="PASS", price_at=10.0,
hard_numbers={"basis": "三门槛全过、无硬风险", "logic": ["研报说好 —— 出处 A", "B", "C", "D"],
"verdict": "候选", "logic_state": hold()},
ref_id="P1", reason="人工采纳: 看好")
f.insert_lot(ts_code=CODE, lot_type="BASE", qty=1000, open_price=10.0, open_date="2026-09-01",
instruction_id="I1")
f.insert_lot(ts_code="600001.SH", lot_type="BASE", qty=1000, open_price=10.0, open_date="2026-09-01")
f.insert_instruction(instruction_id="I2", origin_type="command", origin_id="C1", ts_code="600002.SH",
action="OPEN", side="buy", qty=1000)
f.insert_lot(ts_code="600002.SH", lot_type="BASE", qty=1000, open_price=10.0, open_date="2026-09-02",
instruction_id="I2")
return f
@case("入场论点·有指令链 (批次→指令→提议号→账本) / 外部成交并入 / 账本无行 三种情形都说得出话")
def _():
from app.repo import pms_repo
f = _fake_repo()
with _Patch() as p:
p(pms_repo, "list_lots", f.list_lots)
p(pms_repo, "get_instruction", f.get_instruction)
p(pms_repo, "ledger_by_ref", f.ledger_by_ref)
a = lss.entry_view(CODE)
b = lss.entry_view("600001.SH")
c = lss.entry_view("600002.SH")
d = lss.entry_view("600009.SH")
assert a["basis"] == "三门槛全过、无硬风险" and a["logic"] == ["研报说好 —— 出处 A", "B", "C"], a
assert a["verdict"] == "候选" and a["logic_state_at_entry"] == "逻辑成立" and a["open_date"] == "2026-09-01", a
assert a["instruction_id"] == "I1" and a["arbiter"] == "user", a
assert "外部成交并入" in b["why"] and b["open_date"] == "2026-09-01", b
assert "账本里没有" in c["why"] and c["instruction_id"] == "I2", c
assert "没有未平的批次" in d["why"], d
assert f.ledger_by_ref(["P1", "X"]) and not f.ledger_by_ref([]), "假仓库的按引用取账本"
@case("持仓接口两栏·仓库读失败也不抛, 每行都有 entry 与 logic_now; 没有读数写明; 空仓行 entry 为空")
def _():
from app.repo import pms_repo
rows = [pos(), pos("600001.SH"), pos("600002.SH", total_qty=0)]
with _Patch() as p:
p(pms_repo, "list_lots", lambda *a, **k: (_ for _ in ()).throw(OSError("db down")))
lss.decorate_positions(rows, {CODE: doubt()})
assert rows[0]["logic_now"]["state"] == "逻辑存疑" and "每股收益预测下修" in rows[0]["logic_now"]["text"]
assert rows[0]["logic_now"]["as_of"] == "2026-09-04" and "批次读取失败" in rows[0]["entry"]["why"], rows[0]
assert rows[1]["logic_now"]["state"] is None and "没有读数" in rows[1]["logic_now"]["text"], rows[1]
assert rows[2]["entry"] is None, rows[2]
nv = lss.now_view(hold())
assert nv["state"] == "逻辑成立" and nv["settle_note"] == "维持" and "研报论断" in nv["text"], nv
@case("催化事件与定价状态·解析层归一, 候选与硬数字带原值与整句, 研判白名单只收两句整句 (量价研判链 3.5)")
def _():
ev = {"latest": "2026-09-01", "count": 2, "events": [
{"date": "2026-09-01", "types": ["上调盈利预测", "业绩超预期"], "orgs": ["", ""], "title": "x", "n_reports": 2, "compound": True},
{"date": "2026-08-20", "types": ["深度覆盖"], "orgs": [""], "title": "y", "n_reports": 1, "compound": False},
"不是字典"]}
ps = {"state": "价格发现", "why": "事件前没涨", "event_date": "2026-09-01", "has_event": True, "pre5": 0.01,
"pre20": 0.02, "gap": 0.0, "intraday": 0.01, "close_pos": 0.8, "vol_ratio": 2.0, "day_pct": 0.03,
"limit_up": False, "history_days": 40}
row = pf._rows([{"code": "600000.SH", "rank": 1, "score": 210, "events": ev, "events_text": "催化事件:…",
"pricing_state": ps, "pricing_text": "定价状态(事件日 2026-09-01价格发现。…"}], "main")[0]
assert row["events"]["count"] == 2 and len(row["events"]["events"]) == 2, row["events"]
assert row["events"]["events"][0]["compound"] and row["events"]["events"][0]["orgs"] == ["", ""]
assert row["pricing_state"]["state"] == "价格发现" and row["pricing_state"]["vol_ratio"] == 2.0
assert "history_days" not in row["pricing_state"] and row["pricing_state"]["limit_up"] is False
assert row["events_text"].startswith("催化事件") and row["pricing_text"].startswith("定价状态")
empty = pf._rows([{"code": "600000.SH", "rank": 1, "score": 210, "events": None, "pricing_state": {"state": None}}], "main")[0]
assert empty["events"] is None and empty["pricing_state"] is None and empty["pricing_text"] is None
plan = {"date": "2026-09-04", "main": [row], "observe": []}
item = pf.select_candidates(plan, top_n=10)["items"][0]
assert item["events"]["latest"] == "2026-09-01" and item["pricing_state"]["state"] == "价格发现", item
c = {**item, "price": 10.0}
params = {"scale": 1_000_000, "stock_target_default": 0.06, "batch_split": (0.5, 0.25, 0.25)}
with _Patch() as p:
p(ae, "check_all_caps", lambda **kw: [])
p(ae, "_new_name_ctx", lambda caps, c: caps)
cand, why = ae.eval_open(c, params, {"names_count": 0, "max_names": 10}, 500_000)
hn = cand["hard_numbers"]
assert hn["events"]["count"] == 2 and hn["pricing_state"]["state"] == "价格发现", why
sent = jd._judge_hard_numbers("OPEN", hn)
assert sent["events_text"] == row["events_text"] and sent["pricing_text"] == row["pricing_text"], sent
assert "events" not in sent and "pricing_state" not in sent and "valuation_text" not in sent, sent
assert "events_text" in jd.OPEN_JUDGE_KEYS and "pricing_text" in jd.OPEN_JUDGE_KEYS
@case("参考目标价·状态应答里的估值子字典归一, 持仓行只在算得出时给 ref_target, 算不出或缺就不给")
def _():
val = {"neut": 56.05, "pess": 42.64, "opt": 84.96, "price": 50.17, "quarter": "2026Q4", "firms": 8,
"as_of": "2026-08-28", "na": None, "implied_pe": 21.4, "odds": 0.78}
payload = {"date": "2026-09-04", "states": [
{"input": CODE, "code": "SH600000", "source": "daily", **hold(), "valuation": val, "valuation_text": "安全边际2026 年度预测8 家):…"},
{"input": "600001.SH", "code": "SH600001", "source": "daily", **hold(), "valuation": {"na": "覆盖机构只有 1 家,不足 2 家", "firms": 1}},
{"input": "600002.SH", "code": "SH600002", "source": "computed", **hold()}]}
m = pf.parse_logic_states(payload)
assert m[CODE]["valuation"]["neut"] == 56.05 and m[CODE]["valuation"]["firms"] == 8, m[CODE]
assert "implied_pe" not in m[CODE]["valuation"] and m[CODE]["valuation_text"].startswith("安全边际"), m[CODE]
assert m["600001.SH"]["valuation"]["na"] and m["600002.SH"]["valuation"] is None
rows = [pos(), pos("600001.SH"), pos("600002.SH")]
from app.repo import pms_repo
with _Patch() as p:
p(pms_repo, "list_lots", lambda *a, **k: [])
lss.decorate_positions(rows, m)
assert rows[0]["ref_target"] == {"price": 56.05, "pess": 42.64, "opt": 84.96, "quarter": "2026Q4", "firms": 8,
"as_of": "2026-08-28", "text": "安全边际2026 年度预测8 家):…"}, rows[0]["ref_target"]
assert rows[1]["ref_target"] is None and rows[2]["ref_target"] is None
assert lss.ref_target_view(None) is None and lss.ref_target_view({}) is None
@case("两个期限的头·研判应答归一带出 pv_heads (评分夹区间、方向按评分), 提议硬数字带 judge_pv_heads, 旧应答为 None")
def _():
r = jd._map_verdict({"verdict": "PASS", "reason": "ok", "confidence": 70,
"pv_heads": {"h5": {"score": 1.7, "direction": "看空", "justification": "x"},
"h20": {"score": "-0.1", "justification": "y"}, "h99": {"score": 0.5}}})
assert r["verdict"] == jd.PASS and r["pv_heads"]["h5"] == {"score": 1.0, "direction": "看多", "justification": "x"}, r
assert r["pv_heads"]["h20"]["direction"] == "中性" and "h99" not in r["pv_heads"], r
assert jd._map_verdict({"verdict": "REJECT", "reason": "no"})["pv_heads"] is None
assert jd._map_verdict({"verdict": "UNAVAILABLE", "reason": "x", "pv_heads": {"h5": {"direction": "看多"}}})["pv_heads"] is None
c = {"ts_code": CODE, "action": "OPEN", "side": "buy", "qty": 1000, "reason": "测试新建仓", "price": 10.0,
"hard_numbers": {"price": 10.0, "verdict": "候选"}, "needs_user_confirm": False, "judge_required": True,
"source": ae.SRC_ENGINE, "target_amount": 10000.0, "sector": None}
out, got = _route(c, autonomy="propose_only", judge_resp={**JUDGE_PASS, "pv_heads": {"h5": {"score": 0.4, "direction": "看多", "justification": "a"}}})
assert len(out["queued"]) == 1 and got["proposals"], out
assert got["proposals"][0]["hard_numbers"]["judge_pv_heads"] == {"h5": {"score": 0.4, "direction": "看多", "justification": "a"}}, got["proposals"][0]["hard_numbers"]
out2, got2 = _route(c, autonomy="propose_only", judge_resp=JUDGE_PASS)
assert got2["proposals"][0]["hard_numbers"].get("judge_pv_heads") is None
@case("假仓库·ledger_by_ref 与真 repo 同名同签名 (第十批 [M1] 也会扫, 这里先钉一次)")
def _():
import ast
import inspect
from test_wiring import FakeRepo
path = os.path.join(os.path.dirname(_HERE), "app", "repo", "pms_repo.py")
with open(path, encoding="utf-8") as fh:
names = {n.name: [a.arg for a in n.args.args] for n in ast.parse(fh.read()).body
if isinstance(n, ast.FunctionDef)}
assert names["ledger_by_ref"] == ["ref_ids", "limit"], names["ledger_by_ref"]
fp = list(inspect.signature(FakeRepo.ledger_by_ref).parameters)
assert fp == ["self", "ref_ids", "limit"], fp
# ================================================================ 入口
# ---------- 坏信号集合钉死2026-09-07 方案第五件,一致性检查表第十行) ----------
@case("rule_gate.BAD_Y_SIGNALS 与选股系统 pool.BAD_SIGNALS、择时决策系统 pms_advisor.BAD_Y_SIGNALS 同集合")
def test_bad_y_signals_pinned():
# 三处必须逐字相同选股系统拿它移出候选池、PMS 拿它拦买入、择时决策系统拿它在应答里拦买入。
# 任何一处改取值,三处的测试都会红,逼着一起改。这里钉 PMS 这一处。
from app.core import rule_gate
assert set(rule_gate.BAD_Y_SIGNALS) == {"SELL", "AVOID", "DROPPED"}, sorted(rule_gate.BAD_Y_SIGNALS)
assert isinstance(rule_gate.BAD_Y_SIGNALS, tuple)
def main():
ok = 0
for name, fn in RESULTS:
try:
fn()
ok += 1
print(f" ok {name}")
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
print("-" * 60)
if ok == len(RESULTS):
print(f"ALL PASS ({ok} cases)")
return 0
print(f"FAILED {len(RESULTS) - ok}/{len(RESULTS)}")
return 1
if __name__ == "__main__":
sys.exit(main())