2026-09-03 16:06:27 +08:00
|
|
|
# -*- 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 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
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 11:22:05 +08:00
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 16:06:27 +08:00
|
|
|
@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()
|