tradingSystem/scripts/test_batch21_units.py

490 lines
26 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 -*-
"""
第二十一批模块单测 (减持的自动执行边界, 零外部依赖, 不连库不触网)
==================================================================
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch21_units.py
背景 (2026-09-03 安全修复): 提议分流原来把自动执行写成
auto_exec = (side == "sell") or (autonomy == full and not force_queue)
卖出方向在或运算的左边, 把 force_queue (强制入人工队列) 整个短路了 —— 方向是卖,
这个标记就不起作用。当时没出事, 是因为走到分流的卖出候选只有保垫减仓一种, 它既不强制
确认也不送研判, force_queue 恒为假。但设计里明确要求「研究证据走弱触发的减持必须交人
裁决、绝不自动卖」, 那类减持一旦接上来, 结果会是自动卖出。
本批把修完之后的四件事钉死:
* 规则算出来的保垫减仓仍然自动执行 (老行为一个字不变);
* 带了强制入队标记的减持不再被方向短路 —— 强制入队是一票否决, 排在方向与档位之前;
* 研究证据走弱这个来源必定入人工队列 (预留通道: 目前还没有上游在产出它);
* 两条真正需要自动卖出的路一个字没碰 —— 决策系统高置信风控卖出的自动清仓、用户命令
驱动的清仓, 它们根本不经过提议分流。这两条用「分流函数上装绊线」的办法证明。
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
from datetime import datetime
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 tradedays as td # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
NOW = datetime(2026, 9, 3, 10, 30)
# ================================================================ 夹具
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):
"""把某模块引用的 param_store 取值函数换成一份字典 (口径同第二十批)。"""
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)
PARAMS_BASE = {"PMS_EXEC_WINDOW_TDAYS": 3, "PMS_PROPOSAL_TTL_HOURS": 24,
"PMS_JUDGE_TICK_BUDGET_SEC": 150}
# 研判不可用的应答 (judge.request 的降级形状)
JUDGE_DEGRADED = {"verdict": "UNAVAILABLE", "reason": "决策系统未接通", "degraded": True,
"raw": None, "confidence": None}
JUDGE_PASS = {"verdict": "PASS", "reason": "理由仍成立", "degraded": False,
"raw": {"verdict": "PASS"}, "confidence": 70.0}
def trim_cand(qty=1000, price=10.0):
"""一条真的由动作引擎算出来的保垫减仓候选 (不手搓, 口径与生产一致)。"""
p = {"ts_code": "600000.SH", "cushion_peak": 0.10, "cushion_pct": 0.04,
"total_qty": qty * 3, "price": price}
c = ae.eval_trim(p, {"trim_peak": 0.06, "trim_giveback": 0.5})
assert c and c["action"] == "TRIM" and c["side"] == "sell" and c["qty"] == qty, c
return c
def buy_cand(action="ADD", qty=1000, price=10.0, **kw):
"""一条买入侧候选 (加仓)。判定表用它跟卖出侧对照。"""
c = {"ts_code": "600000.SH", "action": action, "side": "buy", "qty": qty,
"reason": "测试用加仓候选", "hard_numbers": {"price": price},
"needs_user_confirm": False, "judge_required": False, "source": ae.SRC_ENGINE}
c.update(kw)
return c
def _route(c, *, autonomy="propose_only", judge_resp=JUDGE_PASS, dry_run=False,
values=None, price=10.0):
"""把一条候选送进 proposal_service._route_one, 规则闸/研判/落表全换成桩。
回 (out, got): out 是分流结论, got 是这一路真的往库里写了什么。
"""
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("保垫减仓·规则算出来的减持仍然自动执行 (两个档位都是, 老行为一个字不变)")
def _():
for autonomy in ("propose_only", "full"):
out, got = _route(trim_cand(), autonomy=autonomy)
assert len(out["executed"]) == 1 and not out["queued"], (autonomy, out)
assert "减持自动执行" in out["executed"][0]["why"], out["executed"]
assert len(got["instructions"]) == 1 and not got["proposals"], (autonomy, got)
ins = got["instructions"][0]
assert ins["action"] == "TRIM" and ins["side"] == "sell" and ins["qty"] == 1000, ins
assert ins["progress"]["is_command"] is False and ins["progress"]["auto"] is True, ins
# 账本记一条放行, 仲裁人是规则 (减仓不送研判, 见下条用例)
assert got["ledger"][0]["verdict"] == "PASS" and got["ledger"][0]["arbiter"] == "rule"
@case("保垫减仓·不送研判闸 (JUDGE_ACTIONS 只有买入侧四类), 分流不因此改判")
def _():
c = trim_cand()
assert c["judge_required"] is False, c
assert ae.A_TRIM not in ae.JUDGE_ACTIONS and ae.A_OPEN in ae.JUDGE_ACTIONS
out, got = _route(c)
assert got["judge_calls"] == [], got["judge_calls"]
assert len(out["executed"]) == 1, out
@case("判定表·买入侧四种组合逐条对照 (档位与强制入队的老口径一个字不变)")
def _():
table = [
# (档位, 强制确认, 期望自动执行)
("full", False, True),
("full", True, False),
("propose_only", False, False),
("propose_only", True, False),
]
for autonomy, confirm, want_auto in table:
c = buy_cand(needs_user_confirm=confirm)
out, _ = _route(c, autonomy=autonomy)
got_auto = bool(out["executed"])
assert got_auto is want_auto, (autonomy, confirm, want_auto, out)
# ================================================================ 二, 短路修掉了
@case("修复要点·带强制确认标记的减持不再被方向短路 (从前 side==sell 直接自动卖)")
def _():
c = trim_cand()
c["needs_user_confirm"] = True
c["confirm_why"] = "测试用: 这条减持必须交人"
for autonomy in ("propose_only", "full"):
out, got = _route(c, autonomy=autonomy)
assert not out["executed"] and len(out["queued"]) == 1, (autonomy, out)
assert out["queued"][0]["why"] == "测试用: 这条减持必须交人", out["queued"]
assert not got["instructions"] and len(got["proposals"]) == 1, (autonomy, got)
pr = got["proposals"][0]
assert pr["action"] == "TRIM" and pr["qty"] == 1000, pr
assert pr["hard_numbers"]["needs_user_confirm"] is True, pr["hard_numbers"]
@case("修复要点·研判不可用的减持同样入队 (卖出方向不再绕开降级入队这条规矩)")
def _():
# 今天减仓不送研判, 所以这条走的是「将来减仓也进研判范围」的那种处境:
# 候选自己声明要研判, 而研判回不可用 —— 降级入队对卖出必须同样有效。
c = trim_cand()
c["judge_required"] = True
out, got = _route(c, autonomy="full", judge_resp=JUDGE_DEGRADED)
assert got["judge_calls"] == ["TRIM"], got["judge_calls"]
assert not out["executed"] and len(out["queued"]) == 1, out
assert out["queued"][0]["why"] == "研判不可用, 降级人工确认", out["queued"]
assert not got["instructions"] and len(got["proposals"]) == 1, got
# 研判真回了通过时照旧自动执行 (降级入队只针对拿不到结论)
out2, got2 = _route(c, autonomy="full", judge_resp=JUDGE_PASS)
assert len(out2["executed"]) == 1 and not out2["queued"], out2
assert got2["ledger"][0]["arbiter"] == "judge", got2["ledger"]
@case("修复要点·试算口径与真跑一致: 强制入队的减持在 dry_run 里也归入队一列")
def _():
c = trim_cand()
c["needs_user_confirm"] = True
out, got = _route(c, autonomy="full", dry_run=True)
assert not out["executed"] and len(out["queued"]) == 1, out
assert out["queued"][0]["route"] == "queue" and out["queued"][0]["dry_run"] is True, out
assert not got["instructions"] and not got["proposals"], got # 试算滴水不写
# 规则触发的那条在试算里仍是自动执行
out2, _ = _route(trim_cand(), autonomy="full", dry_run=True)
assert out2["executed"] and out2["executed"][0]["route"] == "auto", out2
# ================================================================ 三, 研究证据走弱的预留通道
@case("预留通道·来源常量与强制入队判定 (认不出的来源不影响老行为)")
def _():
assert ae.SRC_ENGINE == "engine" and ae.SRC_RESEARCH_WEAK == "research_weak"
# 名单只查「研究证据走弱在里面、动作引擎自己不在里面」, 不查它一共有几条 ——
# 这份名单本来就是给后来的来源加行用的, 断言写成全等于会拦住正当的新增。
assert ae.FORCE_QUEUE_SOURCES[ae.SRC_RESEARCH_WEAK] == ae.WHY_RESEARCH_WEAK_CONFIRM
assert ae.SRC_ENGINE not in ae.FORCE_QUEUE_SOURCES, ae.FORCE_QUEUE_SOURCES
# 名单里的每一条都必须给得出一句交人的原因 (不许只登记键、原因留空)
for src, why in ae.FORCE_QUEUE_SOURCES.items():
assert isinstance(why, str) and why.strip(), (src, why)
assert ae.source_confirm_why(src) == why, src
assert ae.source_confirm_why(ae.SRC_RESEARCH_WEAK) == ae.WHY_RESEARCH_WEAK_CONFIRM
assert ae.source_confirm_why(" research_weak ") == ae.WHY_RESEARCH_WEAK_CONFIRM
# 没有来源 / 动作引擎自己 / 认不出的来源: 一律不强制, 走原有的方向与档位判定
for x in (None, "", "engine", "signal", "某个将来的来源"):
assert ae.source_confirm_why(x) is None, x
assert "交人裁决" in ae.WHY_RESEARCH_WEAK_CONFIRM
assert "不自动卖出" in ae.WHY_RESEARCH_WEAK_CONFIRM
@case("预留通道·候选构造器产出的形状: 卖出方向 / 强制确认 / 带来源与交人原因")
def _():
c = ae.reduce_on_weak_research("600000.SH", qty=1000,
reason="研究证据走弱: 三条买入理由有两条不再成立",
hard_numbers={"price": 10.0})
assert c["ts_code"] == "600000.SH" and c["action"] == "TRIM" and c["side"] == "sell"
assert c["qty"] == 1000 and c["source"] == ae.SRC_RESEARCH_WEAK, c
assert c["needs_user_confirm"] is True, c
assert c["confirm_why"] == ae.WHY_RESEARCH_WEAK_CONFIRM, c
assert c["hard_numbers"]["price"] == 10.0, c
# 清仓口径也能用同一个构造器
c2 = ae.reduce_on_weak_research("600000.SH", qty=3000, reason="清", action="EXIT")
assert c2["action"] == "EXIT" and c2["side"] == "sell" and c2["source"] == ae.SRC_RESEARCH_WEAK
@case("预留通道·研究证据走弱的减持必定入人工队列 (full 档位也不许自动卖)")
def _():
c = ae.reduce_on_weak_research("600000.SH", qty=1000, reason="研究证据走弱",
hard_numbers={"price": 10.0})
for autonomy in ("propose_only", "full"):
out, got = _route(c, autonomy=autonomy)
assert not out["executed"] and len(out["queued"]) == 1, (autonomy, out)
assert out["queued"][0]["why"] == ae.WHY_RESEARCH_WEAK_CONFIRM, out["queued"]
assert not got["instructions"] and len(got["proposals"]) == 1, (autonomy, got)
assert got["proposals"][0]["hard_numbers"]["source"] == ae.SRC_RESEARCH_WEAK
@case("预留通道·只带来源、没带强制确认标记的减持一样入队 (来源自己就是一票否决)")
def _():
c = trim_cand()
c["source"] = ae.SRC_RESEARCH_WEAK # 只改来源, 强制确认标记仍是假
assert c["needs_user_confirm"] is False and "confirm_why" not in c
out, got = _route(c, autonomy="full")
assert not out["executed"] and len(out["queued"]) == 1, out
assert out["queued"][0]["why"] == ae.WHY_RESEARCH_WEAK_CONFIRM, out["queued"]
assert not got["instructions"] and len(got["proposals"]) == 1, got
@case("留痕·提议硬数字带来源, 人在等我拍板里看得出这条减持是哪来的")
def _():
c = trim_cand()
c["needs_user_confirm"] = True # 先让它入队才有提议可看
_, got = _route(c, autonomy="full")
assert got["proposals"][0]["hard_numbers"]["source"] == ae.SRC_ENGINE, got["proposals"]
c2 = ae.reduce_on_weak_research("600000.SH", qty=1000, reason="研究证据走弱")
_, got2 = _route(c2, autonomy="full")
assert got2["proposals"][0]["hard_numbers"]["source"] == ae.SRC_RESEARCH_WEAK
# ================================================================ 四, 两条自动卖出的路没被碰到
@case("自动止损·风控高置信卖出照旧直接落清仓指令, 且根本不经过提议分流")
def _():
from app.repo import pms_repo
from app.services import param_store, proposal_service as psvc, signal_service as ssvc
got = {"instructions": [], "ledger": [], "proposals": [], "routed": []}
with _Patch() as p:
# 绊线: 这条路要是走进了提议分流, 用例立刻炸 —— 这次改动改的就是那一段
p(psvc, "_route_one", lambda *a, **kw: got["routed"].append(a) or None)
p(pms_repo, "list_instructions", lambda **kw: [])
p(pms_repo, "list_proposals", lambda **kw: [])
p(pms_repo, "insert_instruction", lambda **kw: got["instructions"].append(kw) or 1)
p(pms_repo, "insert_ledger", lambda **kw: got["ledger"].append(kw) or 1)
p(pms_repo, "insert_proposal", lambda **kw: got["proposals"].append(kw) or 1)
_patch_params(p, param_store, PARAMS_BASE)
pos = {"ts_code": "600000.SH", "total_qty": 3000, "avail_qty": 3000, "price": 9.5}
view = {"positions": [pos], "held": [pos]}
prm = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3}
out = {"ok": True, "read": 0, "exits": [], "proposals": [], "recorded": 0,
"ignored": 0, "errors": [], "dry_run": False}
sig = {"ts_code": "600000.SH", "action": "SELL", "confidence": 0.92,
"source": "bionic_risk", "reason": "风控: 形态破位", "msg_id": "1-1"}
ssvc._handle(sig, view, prm, {}, td.ymd(), False, out)
assert got["routed"] == [], "风控卖出不该经过提议分流"
assert len(out["exits"]) == 1 and not out["proposals"], out
assert len(got["instructions"]) == 1 and not got["proposals"], got
ins = got["instructions"][0]
assert ins["action"] == "EXIT" and ins["side"] == "sell" and ins["qty"] == 3000, ins
assert ins["progress"]["urgent"] is True and ins["progress"]["from_signal"] is True, ins
assert ins["progress"]["is_command"] is False, ins
@case("自动止损·到价提议挂着时高置信止损照落, 且把那条到价提议作废留痕 (审查 2026-09-07 第 2 条)")
def _():
# 修之前: 到价清仓 (等你拍板要不要止盈) 被在途检查算作在途, 高置信止损整条被吞 ——
# 不落单不记账, 页面只看到一张止盈提议, 看不出决策系统已判该走。
from app.core import action_engine as ae
from app.repo import pms_repo
from app.services import param_store, proposal_service as psvc, signal_service as ssvc
got = {"instructions": [], "ledger": [], "decided": []}
pending = [{"proposal_id": "PRP_TP", "ts_code": "600000.SH", "action": "EXIT",
"status": "WAIT_USER", "qty": 3000,
"hard_numbers": {"source": ae.SRC_TARGET_PRICE, "price": 12.5}}]
with _Patch() as p:
p(psvc, "_route_one", lambda *a, **kw: (_ for _ in ()).throw(AssertionError("不该走分流")))
p(pms_repo, "list_instructions", lambda **kw: [])
p(pms_repo, "list_proposals", lambda **kw: list(pending))
p(pms_repo, "insert_instruction", lambda **kw: got["instructions"].append(kw) or 1)
p(pms_repo, "insert_ledger", lambda **kw: got["ledger"].append(kw) or 1)
p(pms_repo, "decide_proposal", lambda pid, st: got["decided"].append((pid, st)) or 1)
_patch_params(p, param_store, PARAMS_BASE)
pos = {"ts_code": "600000.SH", "total_qty": 3000, "avail_qty": 3000, "price": 9.5}
view = {"positions": [pos], "held": [pos]}
prm = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3}
out = {"ok": True, "read": 0, "exits": [], "proposals": [], "recorded": 0,
"ignored": 0, "errors": [], "dry_run": False}
sig = {"ts_code": "600000.SH", "action": "SELL", "confidence": 0.92,
"source": "bionic_risk", "reason": "风控: 急跌破位", "msg_id": "1-2"}
ssvc._handle(sig, view, prm, {}, td.ymd(), False, out)
assert len(out["exits"]) == 1 and out["ignored"] == 0, out
assert len(got["instructions"]) == 1 and got["instructions"][0]["action"] == "EXIT", got
assert got["decided"] == [("PRP_TP", "DECLINED")], got["decided"]
notes = [l for l in got["ledger"] if l["verdict"] == "NOTE"]
assert len(notes) == 1 and "到价提议作废" in notes[0]["reason"], notes
@case("自动止损·等人拍板的保垫减仓 (非到价来源) 仍算在途, 老行为不变")
def _():
from app.repo import pms_repo
from app.services import param_store, proposal_service as psvc, signal_service as ssvc
got = {"instructions": []}
pending = [{"proposal_id": "PRP_TRIM", "ts_code": "600000.SH", "action": "TRIM",
"status": "WAIT_USER", "qty": 1000,
"hard_numbers": {"source": "engine", "price": 9.8}}]
with _Patch() as p:
p(psvc, "_route_one", lambda *a, **kw: None)
p(pms_repo, "list_instructions", lambda **kw: [])
p(pms_repo, "list_proposals", lambda **kw: list(pending))
p(pms_repo, "insert_instruction", lambda **kw: got["instructions"].append(kw) or 1)
p(pms_repo, "insert_ledger", lambda **kw: 1)
_patch_params(p, param_store, PARAMS_BASE)
pos = {"ts_code": "600000.SH", "total_qty": 3000, "avail_qty": 3000, "price": 9.5}
view = {"positions": [pos], "held": [pos]}
prm = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3}
out = {"ok": True, "read": 0, "exits": [], "proposals": [], "recorded": 0,
"ignored": 0, "errors": [], "dry_run": False}
sig = {"ts_code": "600000.SH", "action": "SELL", "confidence": 0.92,
"source": "bionic_risk", "reason": "风控: 急跌破位", "msg_id": "1-3"}
ssvc._handle(sig, view, prm, {}, td.ymd(), False, out)
assert out["ignored"] == 1 and got["instructions"] == [], (out, got)
@case("自动止损·中等置信的风控卖出照旧只落提议 (门槛分档没被这次改动碰到)")
def _():
from app.repo import pms_repo
from app.services import param_store, proposal_service as psvc, signal_service as ssvc
got = {"instructions": [], "ledger": [], "proposals": [], "routed": []}
with _Patch() as p:
p(psvc, "_route_one", lambda *a, **kw: got["routed"].append(a) or None)
p(pms_repo, "list_instructions", lambda **kw: [])
p(pms_repo, "list_proposals", lambda **kw: [])
p(pms_repo, "insert_instruction", lambda **kw: got["instructions"].append(kw) or 1)
p(pms_repo, "insert_ledger", lambda **kw: got["ledger"].append(kw) or 1)
p(pms_repo, "insert_proposal", lambda **kw: got["proposals"].append(kw) or 1)
_patch_params(p, param_store, PARAMS_BASE)
pos = {"ts_code": "600000.SH", "total_qty": 3000, "avail_qty": 3000, "price": 9.5}
view = {"positions": [pos], "held": [pos]}
prm = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3}
out = {"ok": True, "read": 0, "exits": [], "proposals": [], "recorded": 0,
"ignored": 0, "errors": [], "dry_run": False}
sig = {"ts_code": "600000.SH", "action": "SELL", "confidence": 0.80,
"source": "bionic_risk", "reason": "风控: 量能转弱", "msg_id": "1-2"}
ssvc._handle(sig, view, prm, {}, td.ymd(), False, out)
assert got["routed"] == [], "风控卖出不该经过提议分流"
assert not out["exits"] and len(out["proposals"]) == 1, out
assert not got["instructions"] and len(got["proposals"]) == 1, got
assert got["proposals"][0]["action"] == "TRIM" and got["proposals"][0]["qty"] == 1000
@case("命令清仓·一键清仓照常出方案, 且根本不经过提议分流")
def _():
from app.core import command_spec as cs
from app.repo import pms_repo
from app.services import (command_service as csvc, param_store, portfolio,
proposal_service as psvc, strategy_service)
got = {"plans": [], "ledger": [], "routed": []}
with _Patch() as p:
p(psvc, "_route_one", lambda *a, **kw: got["routed"].append(a) or None)
p(psvc, "scan_and_route", lambda **kw: got["routed"].append(("scan",)) or {})
p(pms_repo, "update_command", lambda *a, **kw: 1)
p(pms_repo, "insert_plans", lambda rows: got["plans"].extend(rows) or len(rows))
p(pms_repo, "list_plans", lambda **kw: [])
p(pms_repo, "list_instructions", lambda **kw: [])
p(pms_repo, "list_strategies", lambda **kw: [])
p(pms_repo, "list_proposals", lambda **kw: [])
p(pms_repo, "insert_ledger", lambda **kw: got["ledger"].append(kw) or 1)
p(strategy_service, "set_status",
lambda sid, status, by="user": {"ok": True, "status": status})
held = [{"ts_code": "600000.SH", "total_qty": 3000, "avail_qty": 3000, "price": 9.5,
"price_ok": True, "base_qty": 3000}]
p(portfolio, "positions_view", lambda **kw: {
"held": held, "positions": held,
"params": {"scale": 2_000_000, "weak_neg_days": 5},
"totals": {"scale": 2_000_000}, "sector_ready": True})
_patch_params(p, param_store, PARAMS_BASE)
r = csvc.plan_command({"cmd_type": "LIQUIDATE_ALL",
"command_id": "CMD_20260903_0001",
"params": {"window_tdays": 1, "confirm": "YES"}})
assert got["routed"] == [], "用户命令驱动的清仓不该经过提议分流"
assert r["status"] == cs.ST_EXECUTING, r
acts = {x["action"] for x in got["plans"]}
assert "EXIT" in acts, got["plans"]
exits = [x for x in got["plans"] if x["action"] == "EXIT"]
assert exits[0]["ts_code"] == "600000.SH" and exits[0]["qty"] == 3000, exits
# ================================================================ 五, 源码守卫
@case("源码守卫·分流里不许再出现「卖出方向短路强制入队」那种写法")
def _():
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"app", "services", "proposal_service.py")
with open(path, encoding="utf-8") as f:
src = f.read()
# 只看真正的赋值行, 说明性注释里可以原样引用旧写法 (那段注释本身就是这次修复的说明)
bad = [ln for ln in src.splitlines()
if ln.strip().startswith('auto_exec = (side == "sell") or')]
assert not bad, f"卖出方向又被放回或运算左边, 强制入队会再次失效: {bad}"
assert "src_why = ae.source_confirm_why(c.get(\"source\"))" in src, "来源判定不见了"
assert 'auto_exec = (not force_queue) and (side == "sell" or autonomy == AUTONOMY_FULL)' \
in src, "强制入队不再是一票否决"
# ================================================================ 跑
def main():
ok = fail = 0
for name, fn in RESULTS:
try:
fn()
ok += 1
print(f" PASS {name}")
except Exception as e:
fail += 1
print(f" FAIL {name}: {type(e).__name__}: {e}")
traceback.print_exc()
print(f"\n通过 {ok} 例, 失败 {fail} 例")
if fail:
sys.exit(1)
print(f"ALL PASS ({ok} cases)")
if __name__ == "__main__":
main()