tradingSystem/scripts/test_batch34_units.py

342 lines
15 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-14 台账 015)。后端离线纯逻辑单测, 不连库。
把「该不该做」交回三源合议与研判闸。人只处理四类例外: 研判不可用、上游硬风险、合议装不上、
判决词认不出。这里测四块:
A 合议路由的择时异议观察 (consensus.decide timing_dissent)。
B 裁决理由码表与拼句 (decide_reasons.compose)。
C 决策核心 _self_decide: 决定表逐格 (放行强/试探仓/逻辑存疑/深亏补仓/目标价/上限/四类交人)、
四档 off/record/decline/full 的降级。
D 动作引擎 scan_open 打 decide_kind (关掉不打, 逐字回旧) 与 mark_decide 优先级、参数与哨兵。
端到端的自动清仓/自动采纳落表见 test_wiring.py (目标价那两例)。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
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.core import consensus # noqa: E402
from app.core import decide_reasons as dr # noqa: E402
from app.services import judge # noqa: E402
from app.services import param_store # noqa: E402
from app.services.proposal_service import _self_decide # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
def _v(verdict=judge.PASS, conf=78, degraded=False, raw="auto"):
"""研判结果桩。raw 默认给一个字典 (带应答体的真通过); 传 None 模拟「动作不在研判范围」那种
没有应答体的 PASS。"""
r = {"verdict": verdict, "confidence": conf, "degraded": degraded, "reason": ""}
r["raw"] = {"verdict": verdict} if raw == "auto" else raw
return r
def _cand(action=ae.A_OPEN, decide_kind=None, consensus=True, risk=None):
c = {"ts_code": "600000.SH", "action": action, "side": "buy",
"qty": 1000, "reason": "x", "hard_numbers": {}}
if action in (ae.A_EXIT, ae.A_TRIM):
c["side"] = "sell"
if decide_kind:
c["decide_kind"] = decide_kind
if consensus:
c["consensus"] = {"direction": "看多", "route": "放行", "strength": "强"}
if risk:
c["hard_numbers"]["risk"] = risk
return c
# ================================================================ A 合议择时异议
@case("A 合议·方向看多但择时看空: 默认交人 (逐字回旧)")
def _():
d = consensus.decide("看多", "看多", "看空")
assert d["route"] == "交人" and "择时" in d["route_reason"], d
assert "disp" not in d, d
@case("A 合议·自决开 (timing_dissent=observe): 改观察, 等择时转多, disp=wait_timing")
def _():
d = consensus.decide("看多", "看多", "看空", timing_dissent="observe")
assert d["route"] == "观察" and d["route_reason"] == "等择时转多", d
assert d.get("disp") == "wait_timing", d
# 择时不看空时 observe 不改别的格 (三方看多仍放行)
assert consensus.decide("看多", "看多", "看多", timing_dissent="observe")["route"] == "放行"
# ================================================================ B 裁决理由拼句
@case("B decide_reasons·采纳预设码 → 句子与码")
def _():
s, code = dr.compose("ACCEPTED", "ACCEPT_CONSENSUS", None, None)
assert code == "ACCEPT_CONSENSUS" and "合议放行" in s, (s, code)
@case("B decide_reasons·选其他不填补充 → 报错; 填了 → 带补充")
def _():
try:
dr.compose("DECLINED", "DECLINE_OTHER", "", None)
assert False, "空补充应报错"
except ValueError as e:
assert "其他" in str(e) or "补充" in str(e), e
s, code = dr.compose("DECLINED", "DECLINE_OTHER", "估值太贵", None)
assert code == "DECLINE_OTHER" and "估值太贵" in s, (s, code)
@case("B decide_reasons·旧自由文本兼容 / 全空报错 / 码串表报错")
def _():
s, code = dr.compose("ACCEPTED", None, None, "我自己判断成立")
assert code is None and s == "我自己判断成立", (s, code)
try:
dr.compose("ACCEPTED", None, None, None)
assert False
except ValueError:
pass
try:
dr.compose("ACCEPTED", "DECLINE_TECH_WEAK", None, None) # 采纳用了驳回码
assert False
except ValueError as e:
assert "不适用" in str(e), e
# ================================================================ C 决策核心
@case("C 不变式·off 一律不归自决管 (decision none)")
def _():
d = _self_decide(_cand(), _v(), {"self_decide": "off"}, 0)
assert d["decision"] == "none", d
@case("C 放行强·PASS 把握度 78, full → 采纳标准仓")
def _():
d = _self_decide(_cand(), _v(conf=78), {"self_decide": "full"}, 0)
assert d["decision"] == "accept" and d["tier"] == "standard", d
assert d["rule"] == "consensus_strong", d
assert d["why"].startswith("系统采纳"), d
@case("C 放行强·PASS 把握度 40: TRIAL 关转交人 / 开则采纳试探仓")
def _():
off = _self_decide(_cand(), _v(conf=40), {"self_decide": "full", "self_decide_trial": False}, 0)
assert off["decision"] == "confirm", off
on = _self_decide(_cand(), _v(conf=40), {"self_decide": "full", "self_decide_trial": True}, 0)
assert on["decision"] == "accept" and on["tier"] == "trial", on
@case("C 只有一方表态 (weak_consensus): TRIAL 开采纳试探 / 关交人")
def _():
c = _cand(decide_kind=ae.DK_WEAK_CONSENSUS)
on = _self_decide(c, _v(conf=78), {"self_decide": "full", "self_decide_trial": True}, 0)
assert on["decision"] == "accept" and on["tier"] == "trial", on
off = _self_decide(c, _v(conf=78), {"self_decide": "full", "self_decide_trial": False}, 0)
assert off["decision"] == "confirm", off
@case("C 异议只在技术面 (tech_bear): 采纳试探仓")
def _():
d = _self_decide(_cand(decide_kind=ae.DK_TECH_BEAR), _v(conf=78),
{"self_decide": "full", "self_decide_trial": True}, 0)
assert d["decision"] == "accept" and d["tier"] == "trial", d
@case("C 四类交人·研判不可用 / 硬风险 / 合议装不上 / 判决认不出 → confirm")
def _():
fp = {"self_decide": "full", "self_decide_trial": True}
assert _self_decide(_cand(), _v(degraded=True), fp, 0)["decision"] == "confirm"
assert _self_decide(_cand(risk=["高位派发"]), _v(), fp, 0)["decision"] == "confirm"
assert _self_decide(_cand(consensus=False), _v(), fp, 0)["decision"] == "confirm"
assert _self_decide(_cand(decide_kind=ae.DK_VERDICT_UNKNOWN), _v(), fp, 0)["decision"] == "confirm"
@case("C 真通过·PASS 但没有应答体 (动作不在研判范围) → 交人, 不按试探仓采纳")
def _():
fp = {"self_decide": "full", "self_decide_trial": True}
d = _self_decide(_cand(), _v(conf=None, raw=None), fp, 0)
assert d["decision"] == "confirm" and "应答" in d["why"], d
# 深亏补仓同样只认带应答体的 PASS
dca = _cand(action=ae.A_DCA, decide_kind=ae.DK_DCA_DEEP, consensus=False)
assert _self_decide(dca, _v(conf=70, raw=None), fp, 0)["decision"] == "confirm"
# 逻辑存疑排在真通过之前: 不送研判 (raw 为空) 也照样放弃
ld = _cand(decide_kind=ae.DK_LOGIC_DOUBT)
assert _self_decide(ld, {"verdict": "SKIPPED", "raw": None, "degraded": False,
"confidence": None, "reason": ""}, fp, 0)["decision"] == "decline"
@case("C 研判不可用带 judge_unavail 标记 (研判不可用占比复核用)")
def _():
d = _self_decide(_cand(), _v(degraded=True), {"self_decide": "full"}, 0)
assert d["why"].startswith("研判不可用"), d
@case("C 逻辑存疑 (logic_doubt): 自动放弃 decline")
def _():
d = _self_decide(_cand(decide_kind=ae.DK_LOGIC_DOUBT), _v(), {"self_decide": "full"}, 0)
assert d["decision"] == "decline", d
@case("C 深亏补仓: PASS≥60 自动补 / PASS<60 放弃 / 不可用交人")
def _():
dca = _cand(action=ae.A_DCA, decide_kind=ae.DK_DCA_DEEP, consensus=False)
dca["side"] = "buy"
fp = {"self_decide": "full"}
assert _self_decide(dca, _v(conf=70), fp, 0)["decision"] == "accept"
assert _self_decide(dca, _v(conf=40), fp, 0)["decision"] == "decline"
assert _self_decide(dca, _v(degraded=True), fp, 0)["decision"] == "confirm"
@case("C 目标价到价: EXIT 直接采纳 (不进研判范围)")
def _():
ex = _cand(action=ae.A_EXIT, decide_kind=ae.DK_TARGET_PRICE, consensus=False)
d = _self_decide(ex, _v(verdict=judge.PASS, conf=None), {"self_decide": "full"}, 0)
assert d["decision"] == "accept" and d["rule"] == ae.DK_TARGET_PRICE, d
@case("C 每日上限: 达上限 3 时第四只转交人")
def _():
fp = {"self_decide": "full", "self_decide_daily_max": 3}
assert _self_decide(_cand(), _v(conf=78), fp, 2)["decision"] == "accept" # 第三只
over = _self_decide(_cand(), _v(conf=78), fp, 3) # 第四只
assert over["decision"] == "confirm" and "上限" in over["why"], over
@case("C 档位降级·record 只记不做 (would 留原判); decline 只放弃不采纳")
def _():
# record: 本会采纳但 decision 降为 none, would 留 accept
rec = _self_decide(_cand(), _v(conf=78), {"self_decide": "record"}, 0)
assert rec["decision"] == "none" and rec["would"] == "accept", rec
# decline: 采纳降 none, 放弃仍放弃
dec_acc = _self_decide(_cand(), _v(conf=78), {"self_decide": "decline"}, 0)
assert dec_acc["decision"] == "none" and dec_acc["would"] == "accept", dec_acc
dec_dec = _self_decide(_cand(decide_kind=ae.DK_LOGIC_DOUBT), _v(),
{"self_decide": "decline"}, 0)
assert dec_dec["decision"] == "decline", dec_dec
# ================================================================ D 动作引擎与参数
@case("D mark_decide·按优先级取最保守: 认不出 < 逻辑存疑 < 关注 < 试探仓")
def _():
c = {}
ae.mark_decide(c, ae.DK_ADVICE_TRIAL)
assert c["decide_kind"] == ae.DK_ADVICE_TRIAL
ae.mark_decide(c, ae.DK_VERDICT_WATCH) # 更高优先, 覆盖
assert c["decide_kind"] == ae.DK_VERDICT_WATCH
ae.mark_decide(c, ae.DK_LOGIC_DOUBT) # 再高, 覆盖
assert c["decide_kind"] == ae.DK_LOGIC_DOUBT
ae.mark_decide(c, ae.DK_ADVICE_TRIAL) # 更低, 不覆盖
assert c["decide_kind"] == ae.DK_LOGIC_DOUBT
@case("D self_decide_on: off/空 → False; record/decline/full → True")
def _():
assert ae.self_decide_on({"self_decide": "off"}) is False
assert ae.self_decide_on({}) is False
assert ae.self_decide_on({"self_decide": "record"}) is True
assert ae.self_decide_on({"self_decide": "full"}) is True
@case("D eval_dca 深档·自决关不打 decide_kind、confirm 真; 自决开打 dca_deep")
def _():
p = {"ts_code": "688001.SH", "cushion_pct": -0.20, "dca_qty": 0, "dca_count": 0,
"price": 10.0, "base_qty": 2000}
pr = {"dca_triggers": (-0.08, -0.15), "dca_deep_confirm": -0.15, "dca_max_ratio": 0.5}
off = ae.eval_dca(p, pr, {})
assert off and off["needs_user_confirm"] is True and "decide_kind" not in off, off
on = ae.eval_dca(p, {**pr, "self_decide": "full"}, {})
assert on and on.get("decide_kind") == ae.DK_DCA_DEEP, on
def _so_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,
"consensus_route": True, "self_decide": "full"}
p.update(kw)
return p
def _so_caps():
return {"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"}
def _so_cand(route, strength, reason):
con = {"route": route, "route_reason": reason, "direction": "看多", "strength": strength,
"votes": {"fund": "中性", "tech": "看多", "timing": "无读数"}, "reason": "一票 → 看多"}
return {"ts_code": "600000.SH", "price": 10.0, "score": 100.0, "sector": None, "theme": None,
"tier": "强传导", "upside": 0.3, "heat": 0.5, "rank": 1, "bucket": "main",
"src": "plan_api", "consensus": con,
"consensus_blocks": {"fund": {"stance": "中性"}, "tech": {"stance": "看多"},
"timing": {"stance": "无读数"}, "consensus": con}}
@case("D scan_open·弱表态开关关掉 (路由放行但强弱为弱) → 仍打 weak_consensus; 关掉自决不打")
def _():
weak_pass = _so_cand("放行", "弱", "方向看多但只有一方表态(弱表态放行)")
r = ae.scan_open(candidates=[dict(weak_pass)], params=_so_params(), caps=_so_caps(),
room_amt=1e6, slots=10, skip={})
assert r["candidates"], r["skipped"]
assert r["candidates"][0].get("decide_kind") == ae.DK_WEAK_CONSENSUS, r["candidates"][0]
# 弱表态开关开着 (路由交人) 同样是 weak_consensus
weak_confirm = _so_cand("交人", "弱", "方向看多但只有一方表态")
r2 = ae.scan_open(candidates=[dict(weak_confirm)], params=_so_params(), caps=_so_caps(),
room_amt=1e6, slots=10, skip={})
assert r2["candidates"][0].get("decide_kind") == ae.DK_WEAK_CONSENSUS, r2["candidates"][0]
# 自决 off: 不打 decide_kind (逐字回旧)
r3 = ae.scan_open(candidates=[dict(weak_pass)], params=_so_params(self_decide="off"),
caps=_so_caps(), room_amt=1e6, slots=10, skip={})
assert "decide_kind" not in r3["candidates"][0], r3["candidates"][0]
@case("D 参数登记·三键在 RUNTIME_EXTRA, 合法值与范围校验")
def _():
for k in ("PMS_SELF_DECIDE", "PMS_SELF_DECIDE_TRIAL", "PMS_SELF_DECIDE_DAILY_MAX"):
assert k in param_store.RUNTIME_EXTRA, k
assert param_store.RUNTIME_EXTRA["PMS_SELF_DECIDE"][0] == "off" # 代码默认 off
assert param_store.FAIL_CLOSED.get("PMS_SELF_DECIDE") == "off" # 读不到按 off
assert param_store._range_check("PMS_SELF_DECIDE", "full") is None
assert param_store._range_check("PMS_SELF_DECIDE", "wtf") is not None # 非法值被拒
assert param_store._range_check("PMS_SELF_DECIDE_DAILY_MAX", 3) is None
assert param_store._range_check("PMS_SELF_DECIDE_DAILY_MAX", 99) is not None
@case("D repo 两个自决查询函数存在且单表")
def _():
from app.repo import pms_repo
assert callable(pms_repo.system_decided_today) and callable(pms_repo.system_pass_count_today)
assert pms_repo.SYSTEM_ACCEPT_REASON_PREFIX == "系统采纳"
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:
import traceback
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())