diff --git a/app/core/action_engine.py b/app/core/action_engine.py index de0f765..736c727 100644 --- a/app/core/action_engine.py +++ b/app/core/action_engine.py @@ -344,13 +344,22 @@ def eval_target(p: dict, params: dict = None, mkt: dict = None): return None # 取不到现价, 宁可不动 if price < target: 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: return None + note = "" if qty == total else f" (总持仓 {total} 股, 今天只能卖 T+1 可卖的 {qty} 股)" c = _cand(p, A_EXIT, SELL, qty, f"目标价到价: 现价 {price} 已到你设的目标价 {target}, " - f"拟清仓 {qty} 股 (含零股); 卖不卖由你拍板, 系统不自动卖", - {"target_price": target, "price": price, "total_qty": qty, + f"拟清仓 {qty} 股 (含零股){note}; 卖不卖由你拍板, 系统不自动卖", + {"target_price": target, "price": price, "total_qty": total, "avail_qty": int(p.get("avail_qty") or 0), "cushion_pct": p.get("cushion_pct"), "avg_cost": p.get("avg_cost")}, confirm=True, source=SRC_TARGET_PRICE) @@ -358,6 +367,22 @@ def eval_target(p: dict, params: dict = None, mkt: dict = None): 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), (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") if not code or int(p.get("total_qty") or 0) <= 0: continue - # 挂了 ACTIVE 交易方案(策略)的票由策略层接管, 动作引擎不自动提议 (设计 §四) - if code in strategy_codes: + # 挂了 ACTIVE 交易方案(策略)的票由策略层接管, 四类自主动作不评 (设计 §四)。 + # **但目标价那条照评** (2026-09-07 审查修): 目标价是用户下的命令, 优先级高于自动 + # 挂上的方案; 原先整只跳过, 结果是命令表显示生效中、到价那天什么都不发生, + # 且策略层没有任何地方读目标价 —— 一条界面上点了会成功、实际永不触发的命令。 + on_strategy = code in strategy_codes + if on_strategy: skipped.append({"ts_code": code, "action": "*", - "why": "挂了交易方案(策略), 由策略层接管, 动作引擎不自动提议"}) - continue + "why": "挂了交易方案(策略), 由策略层接管, 补足/加仓/补仓/保垫减仓不自动提议; " + "你设的目标价仍照常看"}) # 取不到现价的票整只跳过, **并且留痕**。上游 (portfolio.positions_view) 在拿不到 # 行情时会用摊薄成本顶住 price 让市值还能算, 但那个价不是行情 —— 拿它评动作会得出 # 「安全垫恰好 0」「现价恰好等于成本」这类看着正常、实则凭空的结论。 @@ -432,12 +461,26 @@ def scan(*, positions: list, params: dict, market: dict, skip=None, if sp: p = {**p, "target_price": sp.get("target_price"), "stop_price": sp.get("stop_price")} 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 = [] for action, fn in EVALUATORS: why = skip_why(skip, (code, action)) if why: skipped.append({"ts_code": code, "action": action, "why": why}) 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: skipped.append({"ts_code": code, "action": action, diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index 9fe40d8..7c7876c 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -867,16 +867,23 @@ def _inflight_keys() -> dict: 是为了让人从 skipped 那一行就能接着往下查, 不必再去翻两张表。 """ 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: for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200): - keys[(p["ts_code"], p["action"])] = \ - f"已有在途提议 {p.get('proposal_id')} 在等人确认" + _mark(p["ts_code"], p["action"], f"已有在途提议 {p.get('proposal_id')} 在等人确认") except Exception as e: logger.warning("读提议队列失败: %s", e) try: for i in pms_repo.list_instructions(statuses=list(executor.LIVE), limit=300): - keys[(i["ts_code"], i.get("action"))] = \ - f"已有在途指令 {i.get('instruction_id')} ({i.get('status')})" + _mark(i["ts_code"], i.get("action"), + f"已有在途指令 {i.get('instruction_id')} ({i.get('status')})") except Exception as e: logger.warning("读在途指令失败: %s", e) return keys diff --git a/app/services/signal_service.py b/app/services/signal_service.py index a3e334e..4ce8c4a 100644 --- a/app/services/signal_service.py +++ b/app/services/signal_service.py @@ -21,6 +21,7 @@ import logging from datetime import datetime, timedelta 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 signal_rules as sr 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, reason=d["reason"][:500]) 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 @@ -379,18 +385,53 @@ def _save_seen(seen, ymd): def _has_inflight(code: str) -> bool: + """这只票是否已有在途的卖出, 有则高置信风控卖出不再另落指令。 + + 2026-09-07 审查修: **到价提议不算在途**。目标价到价那条只是「等你拍板要不要止盈」, + 它挂着的时候正是急转向下、风控卖出高发的时候; 原先把它算作在途, 高置信止损整条被吞 + —— 不落单、不记账, 页面上只看到一张止盈提议, 看不出决策系统已判该走。 + 到价提议的来源写在硬数字里 (source = target_price), 按它区分, 不按动作名。 + """ try: for i in pms_repo.list_instructions(statuses=list(executor.LIVE), ts_code=code, limit=20): if str(i.get("side")).lower() == "sell": return True for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200): - if p["ts_code"] == code and p["action"] in ("TRIM", "EXIT"): - return True + 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 except Exception as e: logger.warning("[信号消化] 在途检查失败(按无在途继续): %s", e) 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: """把该股在途的卖出**指令**升级为紧急直通 (确认即加速, 2026-08-18)。 diff --git a/app/web/main.py b/app/web/main.py index 38ed4f0..dfe1c10 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -437,8 +437,15 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default= decision = str(payload.get("decision") or "").upper() if decision not in ("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(): + from app.core import action_engine as ae p = pms_repo.get_proposal(proposal_id) if not p: 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", verdict="PASS" if decision == "ACCEPTED" else "REJECT", 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 if decision == "ACCEPTED": # 策略(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) side = "sell" if p["action"] in ("TRIM", "EXIT") else "buy" _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( 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"), window_tdays=_win, status="PROPOSED", diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 93b0c55..dcfc23d 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -10,7 +10,8 @@ test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 / 用户止损价只披露不拦截 (26 例) test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 + 目标价到价产出清仓候选 (必定交人裁决) + - 同轮只发一条减持 (到价清仓优先于保垫减仓) (15 例) + 同轮只发一条减持 (到价清仓优先于保垫减仓) + + 跨轮减持让路 / 策略票仍评目标价 / 清仓按可卖量报数 (18 例) test_batch5_units.py 决策系统信号流解析与消化口径 (8 例) test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/弃洞/DDL/逐笔入账 (68 例) test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫/ @@ -52,16 +53,17 @@ 页面静态守卫 (17 例) test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/ 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱 - 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 例) + 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流/ + 到价提议挂着时止损照落并作废该提议 (17 例) test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许 直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象 打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) + 目标价到价必定入队 (档位 full 也不自动卖) + 用户设的止损价与目标价单独成列显示 (70 例) - 共 655 例 + 共 660 例 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 - 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 现为 655) + 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 现为 660) 任一子集失败即整体失败 (退出码 1)。 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): diff --git a/scripts/test_batch21_units.py b/scripts/test_batch21_units.py index 75a1d21..084f5c3 100644 --- a/scripts/test_batch21_units.py +++ b/scripts/test_batch21_units.py @@ -326,6 +326,66 @@ def _(): 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 diff --git a/scripts/test_batch4_units.py b/scripts/test_batch4_units.py index ab92e07..98e9d05 100644 --- a/scripts/test_batch4_units.py +++ b/scripts/test_batch4_units.py @@ -253,6 +253,75 @@ def _(): 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("扫描·批次额度与距目标空间口径") def _(): p = pos(market_value=100_000)