第零件甲组:PMS 目标价那条路的四个缺陷,加裁决理由服务端强制

独立审查(09-07)报的两条高严重度与两条中严重度,都在目标价这条路上,都违背 09-03
写下的「目标价必交人、系统不自动卖」。

一,跨轮减持抢在人前面卖。同轮只发一条减持只管一次扫描;下一分钟到价清仓已成在途、
按(代码,动作)被跳过,保垫减仓单独产出、不需确认、卖出方向又不走强制入队,于是
自动卖掉三分之一,人随后采纳的清仓单永远等不到可卖量。修法两处互为保险:动作引擎里
同票只要有任何一条减持在跳过集合里,本轮其余减持一律让路;提议服务的在途集合对减持侧
按代码去重,一条在途两种减持一起记。

二,到价提议挂着时高置信止损被吞。在途检查把等人拍板的到价清仓算作在途,止损整条不落。
到价提议的来源写在硬数字里,按来源区分:等人拍板的止盈不挡止损。止损落单后把还挂着的
到价提议作废并在账本记一行,免得人再点采纳对一只已清掉的票再发清仓。

三,到价当天有买入成交时清仓被整条拒。原先按总持仓报数,规则闸一句「卖出 > T+1 可卖」
拒掉再被当日去重挡住,人当天看不到。改按可卖量报数,与执行器口径对齐;可卖为零不产出。
采纳路径同样按拍板那一刻的可卖量重算。

四,挂了交易方案的票目标价永不触发。原先整只跳过,策略层又没有任何地方读目标价。
改成策略票仍评目标价,其余四类不评。目标价是用户的命令,优先级高于自动挂上的方案。

另收第五件里的一小项:裁决理由改为服务端强制,空理由直接拒绝请求。此前必填只在浏览器
里成立,任何脚本都能写出一条默认文案的裁决记录。

测试:第四批加三例(跨轮让路、策略票评目标价、按可卖量报数),第二十一批加两例
(到价提议不挡止损并被作废、非到价来源的在途仍挡),例数 655 到 660。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-07 11:22:05 +08:00
parent 9a45df38a3
commit db2bca4d90
7 changed files with 258 additions and 19 deletions

View File

@ -344,13 +344,22 @@ def eval_target(p: dict, params: dict = None, mkt: dict = None):
return None # 取不到现价, 宁可不动 return None # 取不到现价, 宁可不动
if price < target: if price < target:
return None # 还没到价 return None # 还没到价
qty = int(p.get("total_qty") or 0) total = int(p.get("total_qty") or 0)
if total <= 0:
return None
# 按 T+1 可卖量报数, 不按全部持仓 (2026-09-07 审查修)。到价当天若有买入成交
# (自动补仓、加仓、做 T 都会), 可卖量小于总持仓; 原先按总持仓报, 规则闸一句
# 「卖出 > T+1 可卖」整条拒掉, 再被当日去重挡住, 人当天根本看不到这条到价提议,
# 明天价格回落就错过。执行器那条路对卖出本来就按可卖量夹紧, 这里与它对齐。
# 可卖量为零 (今天刚建的仓) 不产出: 明天可卖量重置后若仍到价, 自然再来。
qty = clamp_sell_qty(total, p)
if qty <= 0: if qty <= 0:
return None return None
note = "" if qty == total else f" (总持仓 {total} 股, 今天只能卖 T+1 可卖的 {qty} 股)"
c = _cand(p, A_EXIT, SELL, qty, c = _cand(p, A_EXIT, SELL, qty,
f"目标价到价: 现价 {price} 已到你设的目标价 {target}, " f"目标价到价: 现价 {price} 已到你设的目标价 {target}, "
f"拟清仓 {qty} 股 (含零股); 卖不卖由你拍板, 系统不自动卖", f"拟清仓 {qty} 股 (含零股){note}; 卖不卖由你拍板, 系统不自动卖",
{"target_price": target, "price": price, "total_qty": qty, {"target_price": target, "price": price, "total_qty": total,
"avail_qty": int(p.get("avail_qty") or 0), "avail_qty": int(p.get("avail_qty") or 0),
"cushion_pct": p.get("cushion_pct"), "avg_cost": p.get("avg_cost")}, "cushion_pct": p.get("cushion_pct"), "avg_cost": p.get("avg_cost")},
confirm=True, source=SRC_TARGET_PRICE) confirm=True, source=SRC_TARGET_PRICE)
@ -358,6 +367,22 @@ def eval_target(p: dict, params: dict = None, mkt: dict = None):
return c return c
def clamp_sell_qty(qty: int, pos: dict) -> int:
"""卖出数量按 T+1 可卖量夹紧。可卖量缺失 (None) 时按总持仓, 不当成零。
页面采纳一条清仓提议时也走这里: 提议记的是扫描那一刻的股数, 等人拍板时持仓
可能已经被别的路卖少了, 照原数落单会被卖出前的检查整条驳回, 人点了清掉
结果一股没卖按拍板那一刻的可卖量重算, 才能保证人说卖, 就真的卖得出去
"""
q = int(qty or 0)
if q <= 0:
return 0
avail = pos.get("avail_qty")
if avail is None:
return q
return max(0, min(q, int(avail or 0)))
EVALUATORS = ((A_TRIM, eval_trim), (A_EXIT, eval_target), EVALUATORS = ((A_TRIM, eval_trim), (A_EXIT, eval_target),
(A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca)) (A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca))
@ -410,11 +435,15 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
code = p.get("ts_code") code = p.get("ts_code")
if not code or int(p.get("total_qty") or 0) <= 0: if not code or int(p.get("total_qty") or 0) <= 0:
continue continue
# 挂了 ACTIVE 交易方案(策略)的票由策略层接管, 动作引擎不自动提议 (设计 §四) # 挂了 ACTIVE 交易方案(策略)的票由策略层接管, 四类自主动作不评 (设计 §四)。
if code in strategy_codes: # **但目标价那条照评** (2026-09-07 审查修): 目标价是用户下的命令, 优先级高于自动
# 挂上的方案; 原先整只跳过, 结果是命令表显示生效中、到价那天什么都不发生,
# 且策略层没有任何地方读目标价 —— 一条界面上点了会成功、实际永不触发的命令。
on_strategy = code in strategy_codes
if on_strategy:
skipped.append({"ts_code": code, "action": "*", skipped.append({"ts_code": code, "action": "*",
"why": "挂了交易方案(策略), 由策略层接管, 动作引擎不自动提议"}) "why": "挂了交易方案(策略), 由策略层接管, 补足/加仓/补仓/保垫减仓不自动提议; "
continue "你设的目标价仍照常看"})
# 取不到现价的票整只跳过, **并且留痕**。上游 (portfolio.positions_view) 在拿不到 # 取不到现价的票整只跳过, **并且留痕**。上游 (portfolio.positions_view) 在拿不到
# 行情时会用摊薄成本顶住 price 让市值还能算, 但那个价不是行情 —— 拿它评动作会得出 # 行情时会用摊薄成本顶住 price 让市值还能算, 但那个价不是行情 —— 拿它评动作会得出
# 「安全垫恰好 0」「现价恰好等于成本」这类看着正常、实则凭空的结论。 # 「安全垫恰好 0」「现价恰好等于成本」这类看着正常、实则凭空的结论。
@ -432,12 +461,26 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
if sp: if sp:
p = {**p, "target_price": sp.get("target_price"), "stop_price": sp.get("stop_price")} p = {**p, "target_price": sp.get("target_price"), "stop_price": sp.get("stop_price")}
frozen = (p.get("frozen_reason") or "NONE") != "NONE" frozen = (p.get("frozen_reason") or "NONE") != "NONE"
# 跨轮的减持让路 (2026-09-07 审查修)。同轮只发一条减持那条规矩 (见下) 只管一次扫描;
# 下一分钟再扫时, 等人拍板的到价清仓已成在途、按 (代码, EXIT) 被跳过, 而保垫减仓
# 单独产出、不需确认、卖出方向又不走强制入队 —— 系统刚说「卖不卖由你定」, 一分钟后
# 自己卖了三分之一, 人随后采纳的清仓单永远等不到可卖量。
# 修法: 这只票只要有任何一条减持在跳过集合里 (在途提议、在途指令、今日被拒过),
# 本轮其余减持一律让路。宁可少卖一次, 不能抢在人前面卖。
inflight_sell = next((a for a in SELL_SIDE_ACTIONS if skip_why(skip, (code, a))), None)
cands_this = [] cands_this = []
for action, fn in EVALUATORS: for action, fn in EVALUATORS:
why = skip_why(skip, (code, action)) why = skip_why(skip, (code, action))
if why: if why:
skipped.append({"ts_code": code, "action": action, "why": why}) skipped.append({"ts_code": code, "action": action, "why": why})
continue continue
if on_strategy and action != A_EXIT:
continue # 策略票只看目标价, 原因已在上面记过
if inflight_sell and action in SELL_SIDE_ACTIONS:
skipped.append({"ts_code": code, "action": action,
"why": f"这只票已有一条减持 ({inflight_sell}) 在处理中, "
f"处理完之前不再另发减持, 免得抢在人前面卖"})
continue
# 冻结只禁增持, 减仓照评 (与规则闸同一口径, 这里先剪枝少算一遍) # 冻结只禁增持, 减仓照评 (与规则闸同一口径, 这里先剪枝少算一遍)
if frozen and action not in SELL_SIDE_ACTIONS: if frozen and action not in SELL_SIDE_ACTIONS:
skipped.append({"ts_code": code, "action": action, skipped.append({"ts_code": code, "action": action,

View File

@ -867,16 +867,23 @@ def _inflight_keys() -> dict:
是为了让人从 skipped 那一行就能接着往下查, 不必再去翻两张表 是为了让人从 skipped 那一行就能接着往下查, 不必再去翻两张表
""" """
keys = {} keys = {}
# 减持侧按**代码**去重, 不按 (代码, 动作) (2026-09-07 审查修): 一只票有一条清仓在等人拍板时,
# 保垫减仓若按另一个动作名单独放行, 会抢在人前面自动卖掉一部分。所以一条减持在途,
# 这只票两种减持一起记进跳过集合。动作引擎那边还有一道同样的闸, 两处互为保险。
def _mark(code, action, why):
keys[(code, action)] = why
if action in ae.SELL_SIDE_ACTIONS:
for other in ae.SELL_SIDE_ACTIONS:
keys.setdefault((code, other), why + " (同票另一种减持一并让路)")
try: try:
for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200): for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200):
keys[(p["ts_code"], p["action"])] = \ _mark(p["ts_code"], p["action"], f"已有在途提议 {p.get('proposal_id')} 在等人确认")
f"已有在途提议 {p.get('proposal_id')} 在等人确认"
except Exception as e: except Exception as e:
logger.warning("读提议队列失败: %s", e) logger.warning("读提议队列失败: %s", e)
try: try:
for i in pms_repo.list_instructions(statuses=list(executor.LIVE), limit=300): for i in pms_repo.list_instructions(statuses=list(executor.LIVE), limit=300):
keys[(i["ts_code"], i.get("action"))] = \ _mark(i["ts_code"], i.get("action"),
f"已有在途指令 {i.get('instruction_id')} ({i.get('status')})" f"已有在途指令 {i.get('instruction_id')} ({i.get('status')})")
except Exception as e: except Exception as e:
logger.warning("读在途指令失败: %s", e) logger.warning("读在途指令失败: %s", e)
return keys return keys

View File

@ -21,6 +21,7 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from config.settings import settings from config.settings import settings
from app.core import action_engine as ae
from app.core import command_spec as cs from app.core import command_spec as cs
from app.core import signal_rules as sr from app.core import signal_rules as sr
from app.core import tradedays as td from app.core import tradedays as td
@ -284,6 +285,11 @@ def _make_exit(code, d, pos) -> str:
hard_numbers=d["hard_numbers"], ref_id=iid, hard_numbers=d["hard_numbers"], ref_id=iid,
reason=d["reason"][:500]) reason=d["reason"][:500])
logger.warning("[信号消化] %s 转清仓指令 %s —— %s", code, iid, d["reason"]) logger.warning("[信号消化] %s 转清仓指令 %s —— %s", code, iid, d["reason"])
# 清仓指令已落, 这只票还挂着的到价提议作废 (2026-09-07): 不作废它会挂到次日日结,
# 人再点采纳会对一只已经清掉的票再发一条清仓。
retired = _retire_target_proposals(code, d["reason"])
if retired:
logger.warning("[信号消化] %s 到价提议 %s 已因止损直通作废", code, retired)
return iid return iid
@ -379,18 +385,53 @@ def _save_seen(seen, ymd):
def _has_inflight(code: str) -> bool: def _has_inflight(code: str) -> bool:
"""这只票是否已有在途的卖出, 有则高置信风控卖出不再另落指令。
2026-09-07 审查修: **到价提议不算在途**目标价到价那条只是等你拍板要不要止盈,
它挂着的时候正是急转向下风控卖出高发的时候; 原先把它算作在途, 高置信止损整条被吞
不落单不记账, 页面上只看到一张止盈提议, 看不出决策系统已判该走
到价提议的来源写在硬数字里 (source = target_price), 按它区分, 不按动作名
"""
try: try:
for i in pms_repo.list_instructions(statuses=list(executor.LIVE), ts_code=code, limit=20): for i in pms_repo.list_instructions(statuses=list(executor.LIVE), ts_code=code, limit=20):
if str(i.get("side")).lower() == "sell": if str(i.get("side")).lower() == "sell":
return True return True
for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200): for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200):
if p["ts_code"] == code and p["action"] in ("TRIM", "EXIT"): if p["ts_code"] != code or p["action"] not in ("TRIM", "EXIT"):
continue
if str((p.get("hard_numbers") or {}).get("source") or "") == ae.SRC_TARGET_PRICE:
continue # 等人拍板的止盈, 不挡止损
return True return True
except Exception as e: except Exception as e:
logger.warning("[信号消化] 在途检查失败(按无在途继续): %s", e) logger.warning("[信号消化] 在途检查失败(按无在途继续): %s", e)
return False return False
def _retire_target_proposals(code: str, why: str) -> list:
"""风控高置信卖出已直通清仓后, 把这只票还挂着的到价提议作废并留痕。
不作废的话它会一直挂到次日日结, 人若再点采纳, 会对一只已经清掉的票再发一条清仓
作废写成 DECLINED 并在账本记一行 NOTE, 复盘时看得出是被止损顶掉的, 不是人驳回的
"""
retired = []
try:
for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200):
if p["ts_code"] != code:
continue
if str((p.get("hard_numbers") or {}).get("source") or "") != ae.SRC_TARGET_PRICE:
continue
if pms_repo.decide_proposal(p["proposal_id"], "DECLINED"):
pms_repo.insert_ledger(
ts_code=code, action=p["action"], arbiter="rule", verdict="NOTE",
price_at=float((p.get("hard_numbers") or {}).get("price") or 0),
hard_numbers=p.get("hard_numbers") or {}, ref_id=p["proposal_id"],
reason=f"到价提议作废: 风控高置信卖出已直通清仓 ({why})"[:500])
retired.append(p["proposal_id"])
except Exception as e:
logger.warning("[信号消化] 作废到价提议失败(清仓指令已落, 不影响): %s", e)
return retired
def _escalate_inflight_sells(code: str, reason: str) -> list: def _escalate_inflight_sells(code: str, reason: str) -> list:
"""把该股在途的卖出**指令**升级为紧急直通 (确认即加速, 2026-08-18)。 """把该股在途的卖出**指令**升级为紧急直通 (确认即加速, 2026-08-18)。

View File

@ -437,8 +437,15 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
decision = str(payload.get("decision") or "").upper() decision = str(payload.get("decision") or "").upper()
if decision not in ("ACCEPTED", "DECLINED"): if decision not in ("ACCEPTED", "DECLINED"):
return {"ok": False, "error": "decision 必须是 ACCEPTED 或 DECLINED"} return {"ok": False, "error": "decision 必须是 ACCEPTED 或 DECLINED"}
# 理由在服务端强制 (2026-09-07): 页面 09-03 起就弹必填框, 但只在浏览器里成立 ——
# 任何脚本、旧缓存页面都能发一条没有理由的裁决, 账本上落一句「页面人工裁决」的
# 默认文案, 与真裁决看不出区别。复盘要的是「人为什么这么判」, 没理由的不收。
reason = str(payload.get("reason") or "").strip()
if not reason:
return {"ok": False, "error": "采纳或驳回都要写理由, 空理由不收"}
def _decide(): def _decide():
from app.core import action_engine as ae
p = pms_repo.get_proposal(proposal_id) p = pms_repo.get_proposal(proposal_id)
if not p: if not p:
return {"ok": False, "error": "提议不存在"} return {"ok": False, "error": "提议不存在"}
@ -450,7 +457,7 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
pms_repo.insert_ledger(ts_code=p["ts_code"], action=p["action"], arbiter="user", pms_repo.insert_ledger(ts_code=p["ts_code"], action=p["action"], arbiter="user",
verdict="PASS" if decision == "ACCEPTED" else "REJECT", verdict="PASS" if decision == "ACCEPTED" else "REJECT",
price_at=float(hn.get("price") or 0), hard_numbers=hn, price_at=float(hn.get("price") or 0), hard_numbers=hn,
ref_id=proposal_id, reason=payload.get("reason") or "页面人工裁决") ref_id=proposal_id, reason=reason)
instruction_id = None instruction_id = None
if decision == "ACCEPTED": if decision == "ACCEPTED":
# 策略(confirm 档)提议: 两腿同 action、side 无法由 action 反推, 交策略层按腿谱发指令 # 策略(confirm 档)提议: 两腿同 action、side 无法由 action 反推, 交策略层按腿谱发指令
@ -464,9 +471,19 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
instruction_id = cs.make_instruction_id(td.ymd(), p["ts_code"], p["action"], 1) instruction_id = cs.make_instruction_id(td.ymd(), p["ts_code"], p["action"], 1)
side = "sell" if p["action"] in ("TRIM", "EXIT") else "buy" side = "sell" if p["action"] in ("TRIM", "EXIT") else "buy"
_win = param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3) _win = param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3)
qty = int(p["qty"] or 0)
if side == "sell":
# 按拍板这一刻的可卖量重算 (2026-09-07 审查修): 提议记的是扫描那一刻的股数,
# 等人点采纳时持仓可能已被别的路卖少, 照原数落单会被卖出前的检查整条驳回,
# 人点了「清掉」结果一股没卖, 且那条指令会永久在途、挡住后续所有清仓。
cur = pms_repo.get_position(p["ts_code"]) or {}
qty = ae.clamp_sell_qty(qty, cur)
if qty <= 0:
return {"ok": True, "decision": decision, "instruction_id": None,
"note": "这只票现在没有可卖的股数 (已被清掉或今天刚买入), 未落单"}
pms_repo.insert_instruction( pms_repo.insert_instruction(
instruction_id=instruction_id, origin_type="proposal", origin_id=proposal_id, instruction_id=instruction_id, origin_type="proposal", origin_id=proposal_id,
ts_code=p["ts_code"], action=p["action"], side=side, qty=int(p["qty"] or 0), ts_code=p["ts_code"], action=p["action"], side=side, qty=qty,
limit_price=hn.get("price"), limit_price=hn.get("price"),
window_tdays=_win, window_tdays=_win,
status="PROPOSED", status="PROPOSED",

View File

@ -10,7 +10,8 @@
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 / 用户止损价只披露不拦截 (26 ) test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 / 用户止损价只披露不拦截 (26 )
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 + test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 +
目标价到价产出清仓候选 (必定交人裁决) + 目标价到价产出清仓候选 (必定交人裁决) +
同轮只发一条减持 (到价清仓优先于保垫减仓) (15 ) 同轮只发一条减持 (到价清仓优先于保垫减仓) +
跨轮减持让路 / 策略票仍评目标价 / 清仓按可卖量报数 (18 )
test_batch5_units.py 决策系统信号流解析与消化口径 (8 ) test_batch5_units.py 决策系统信号流解析与消化口径 (8 )
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/弃洞/DDL/逐笔入账 (68 ) test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/弃洞/DDL/逐笔入账 (68 )
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫/ test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫/
@ -52,16 +53,17 @@
页面静态守卫 (17 ) 页面静态守卫 (17 )
test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/ test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 ) 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流/
到价提议挂着时止损照落并作废该提议 (17 )
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许 test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象 直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 ) 打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) + test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) + 目标价到价必定入队 (档位 full 也不自动卖) +
用户设的止损价与目标价单独成列显示 (70 ) 用户设的止损价与目标价单独成列显示 (70 )
655 660
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 现为 655) 减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 655; 09-07 审查修复加了跨轮减持等五例, 现为 660)
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):

View File

@ -326,6 +326,66 @@ def _():
assert ins["progress"]["is_command"] is False, 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("自动止损·中等置信的风控卖出照旧只落提议 (门槛分档没被这次改动碰到)") @case("自动止损·中等置信的风控卖出照旧只落提议 (门槛分档没被这次改动碰到)")
def _(): def _():
from app.repo import pms_repo from app.repo import pms_repo

View File

@ -253,6 +253,75 @@ def _():
assert r3["candidates"] == [] assert r3["candidates"] == []
@case("跨轮·同票已有减持在途时, 本轮其余减持一律让路 (审查 2026-09-07 第 1 条)")
def _():
# 第一分钟: 到价清仓 (等人拍板) + 保垫减仓同时触发, 同轮只留到价那条。
# 第二分钟: 到价清仓已成在途、按 (代码, EXIT) 进了跳过集合 —— 修之前保垫减仓单独
# 产出、不需确认、当场自动卖掉 1/3, 系统刚说「卖不卖由你定」一分钟后自己先卖了。
ps = [pos(ts_code="600000.SH", price=13.0, total_qty=6000, avail_qty=6000,
cushion_peak=0.20, cushion_pct=0.09, market_value=78_000)]
m = {"600000.SH": mkt(ma5=12.0, high5=13.0)}
sp = {"600000.SH": {"target_price": 12.5}}
r1 = ae.scan(positions=ps, params=PARAMS, market=m, stock_params=sp)
assert [c["action"] for c in r1["candidates"] if c["side"] == ae.SELL] == [ae.A_EXIT]
# 第二轮: 模拟 proposal_service 把在途的到价清仓放进跳过集合
r2 = ae.scan(positions=ps, params=PARAMS, market=m, stock_params=sp,
skip={("600000.SH", ae.A_EXIT): "已有在途提议 PRP_x 在等人确认"})
sells2 = [c for c in r2["candidates"] if c["side"] == ae.SELL]
assert sells2 == [], f"在途清仓等人拍板时保垫减仓不许单独产出: {sells2}"
whys = [s["why"] for s in r2["skipped"] if s["ts_code"] == "600000.SH"]
assert any("在处理中" in w and "抢在人前面" in w for w in whys), whys
# 在途的是 TRIM 时同理: 目标价到价也让路 (人已经在处理这只票的减持)
r3 = ae.scan(positions=ps, params=PARAMS, market=m, stock_params=sp,
skip={("600000.SH", ae.A_TRIM): "已有在途指令 INS_x (DISPATCHED)"})
assert [c for c in r3["candidates"] if c["side"] == ae.SELL] == []
# 跳过集合里只有买入侧时, 减持不受影响 (老行为)
r4 = ae.scan(positions=ps, params=PARAMS, market=m, stock_params=sp,
skip={("600000.SH", ae.A_ADD): "x"})
assert [c["action"] for c in r4["candidates"] if c["side"] == ae.SELL] == [ae.A_EXIT]
@case("策略票·挂了交易方案的票仍评目标价, 其余四类不评 (审查 2026-09-07 第 5 条)")
def _():
# 修之前 scan() 对策略票整只 continue, 目标价命令显示生效、到价那天什么都不发生。
ps = [pos(ts_code="600000.SH", price=13.0, total_qty=6000, avail_qty=6000,
cushion_peak=0.20, cushion_pct=0.09, market_value=78_000)]
m = {"600000.SH": mkt(ma5=12.0, high5=13.0)}
r = ae.scan(positions=ps, params=PARAMS, market=m, strategy_codes={"600000.SH"},
stock_params={"600000.SH": {"target_price": 12.5}})
acts = [c["action"] for c in r["candidates"]]
assert acts == [ae.A_EXIT], f"策略票只该产出到价清仓: {acts}"
assert r["candidates"][0]["needs_user_confirm"] is True
assert any("你设的目标价仍照常看" in s["why"] for s in r["skipped"]), r["skipped"]
# 没设目标价时策略票什么都不产出 (老行为一个字不变)
r2 = ae.scan(positions=ps, params=PARAMS, market=m, strategy_codes={"600000.SH"})
assert r2["candidates"] == [], r2["candidates"]
@case("到价清仓·按 T+1 可卖量报数, 可卖为零不产出 (审查 2026-09-07 第 4 条)")
def _():
# 到价当天有买入成交时可卖量小于总持仓; 原先按总持仓报, 规则闸「卖出 > T+1 可卖」
# 整条拒掉再被当日去重挡住, 人当天看不到这条到价提议。
m = {"600000.SH": mkt(ma5=12.0, high5=13.0)}
sp = {"600000.SH": {"target_price": 12.5}}
p1 = pos(ts_code="600000.SH", price=13.0, total_qty=1000, avail_qty=800,
cushion_pct=0.30, cushion_peak=0.30, market_value=13_000)
r = ae.scan(positions=[p1], params=PARAMS, market=m, stock_params=sp)
ex = [c for c in r["candidates"] if c["action"] == ae.A_EXIT]
assert len(ex) == 1 and ex[0]["qty"] == 800, ex
assert ex[0]["hard_numbers"]["total_qty"] == 1000 and ex[0]["hard_numbers"]["avail_qty"] == 800
assert "今天只能卖 T+1 可卖的 800 股" in ex[0]["reason"], ex[0]["reason"]
p0 = pos(ts_code="600000.SH", price=13.0, total_qty=1000, avail_qty=0,
cushion_pct=0.30, cushion_peak=0.30, market_value=13_000)
r0 = ae.scan(positions=[p0], params=PARAMS, market=m, stock_params=sp)
assert not [c for c in r0["candidates"] if c["action"] == ae.A_EXIT], r0["candidates"]
# 可卖量缺失 (None) 按总持仓, 不当成零 —— 旧数据行没有这个字段
assert ae.clamp_sell_qty(6000, {"avail_qty": None}) == 6000
assert ae.clamp_sell_qty(6000, {"avail_qty": 2000}) == 2000
assert ae.clamp_sell_qty(6000, {"avail_qty": 0}) == 0
assert ae.clamp_sell_qty(0, {"avail_qty": 9999}) == 0
@case("扫描·批次额度与距目标空间口径") @case("扫描·批次额度与距目标空间口径")
def _(): def _():
p = pos(market_value=100_000) p = pos(market_value=100_000)