From c6034a62202c50c0ca824439d84118667a935e03 Mon Sep 17 00:00:00 2001 From: zlt Date: Thu, 3 Sep 2026 16:06:27 +0800 Subject: [PATCH] =?UTF-8?q?PMS=20=E4=B8=A4=E5=A4=84=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=BC=BA=E5=88=B6=E4=BA=A4=E4=BA=BA?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=B8=80=E7=A5=A8=E5=90=A6=E5=86=B3=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A2=AB=E5=8D=96=E5=87=BA=E6=96=B9=E5=90=91=E7=9F=AD?= =?UTF-8?q?=E8=B7=AF=EF=BC=9B=E7=9B=AE=E6=A0=87=E4=BB=B7=E4=B8=8E=E6=AD=A2?= =?UTF-8?q?=E6=8D=9F=E4=BB=B7=E6=8E=A5=E9=80=9A=E4=B8=94=E5=BF=85=E5=AE=9A?= =?UTF-8?q?=E4=BA=A4=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 卖出侧:原判定把卖出方向放在或运算左边,强制交人的标记被整个短路。改为强制交人一票否决排在方向与档位之前。真正需要自动卖的两条路(风控高置信止损、用户命令清仓)不经提议分流,不受影响。 目标价:接通而非摘掉入口。到价产出清仓提议但必定交人,文案写明系统不自动卖;拿不到现价时不动。止损价参与规则检查,只告警不拦截。填 0 即取消,已写进命令说明与页面提示。 复核发现并修:同一轮可能同时产出保垫减仓与到价清仓,前者当场执行后者等人拍板,等人采纳时数量已超过实际持仓会被整条驳回。改为同轮只发一条减持,用户设的目标价优先。 测试:全套 ALL SUITES PASS,例数 626 增至 650。 Co-Authored-By: Claude Opus 5 --- app/core/action_engine.py | 163 +++++++++++- app/core/command_spec.py | 14 +- app/core/rule_gate.py | 38 ++- app/services/executor.py | 3 + app/services/param_store.py | 40 +++ app/services/proposal_service.py | 58 ++++- app/web/static/index.html | 6 +- scripts/run_tests.py | 24 +- scripts/test_batch10_units.py | 119 +++++++++ scripts/test_batch12_units.py | 6 +- scripts/test_batch21_units.py | 428 +++++++++++++++++++++++++++++++ scripts/test_batch3_units.py | 29 +++ scripts/test_batch4_units.py | 75 ++++++ scripts/test_wiring.py | 82 ++++++ 14 files changed, 1047 insertions(+), 38 deletions(-) create mode 100644 scripts/test_batch21_units.py diff --git a/app/core/action_engine.py b/app/core/action_engine.py index ec653ee..a04aae0 100644 --- a/app/core/action_engine.py +++ b/app/core/action_engine.py @@ -10,6 +10,7 @@ | ADD 盈利加仓 | 安全垫 ≥ +3% 且创 5 日新高或站上压力位 | 距上次 ≥2 交易日; ≤ 单股上限; 距 MA5 <+6% | | DCA 补仓 | 浮亏触及 −8%/−15% 评估档 (各评估一次, 执行终身一次) | ≤ 底仓 50%; −15% 及更深永远需用户确认 | | TRIM 保垫减仓 | 安全垫峰值 ≥6% 且回吐过半 → 减 1/3 锁盈 | 纯规则自动执行 (减持方向不设确认门槛) | +| EXIT 目标价到价 | 现价 ≥ 用户「设定某股目标价」命令里的价 | 全部持仓; **必定交人拍板, 绝不自动卖** | | OPEN 新建仓 | 上游候选池里的新票, 且还有持仓名额与可投金额 | 名额与金额边走边扣; 只提底仓批 | 本模块只回答「该不该动、动多少、为什么」, 不查库不下发: @@ -24,7 +25,7 @@ 输出候选统一结构, 供 proposal_service 走 规则闸 → 研判闸 → 按自主档位分流。 扫描入口有两个, 输入不同, 互不影响: - scan() 输入是**已有持仓**, 产出 FILL/ADD/DCA/TRIM (2026-08-06 之前就有的四类) + scan() 输入是**已有持仓**, 产出 FILL/ADD/DCA/TRIM 与 EXIT (目标价到价, 2026-09-03 新增) scan_open() 输入是**上游候选池**, 产出 OPEN (2026-08-06 新增) 候选池取不到时 scan() 照常跑, 反之亦然 —— 一条外部接口的故障不该让整轮扫描停摆。 """ @@ -40,8 +41,51 @@ from app.core.planner import check_all_caps, _ctx_after, _new_name_ctx A_FILL, A_ADD, A_DCA, A_TRIM = "FILL", "ADD", "DCA", "TRIM" A_OPEN = "OPEN" # 新建仓 (与 planner.A_OPEN、executor.BUY_ACTIONS 同名同义) +# 清仓 (与 planner.A_EXIT、executor.SELL_ACTIONS、页面动作词表同名同义)。 +# 目标价到价产出的退出提议用的就是它 —— 不新造动作名, 是为了让「采纳提议 → 落指令」那一步 +# (web/main.py 按动作反推买卖方向) 与执行器的清仓口径 (允许零股一次性卖出) 直接复用现成的路。 +A_EXIT = "EXIT" BUY, SELL = "buy", "sell" +# 同一轮里出现多条减持时留哪一条 (2026-09-03): 数字小的优先。用户自己设的目标价到价排在 +# 系统按规则算出来的保垫减仓前面 —— 人已经说了到价就清, 这一轮就不该再自作主张先卖一部分。 +# 只在同一只票的同一轮里比较, 不影响不同票, 也不影响下一轮。 +_SELL_PRIORITY = {A_EXIT: 0, A_TRIM: 1} + +# 候选来源 (2026-09-03)。每条候选都带一个来源, 提议分流那一步拿它决定「这条减持能不能不等人」。 +# +# 为什么需要这一项: 分流从前写的是「卖出方向一律自动执行」, 只看方向、不看来源。那条规矩 +# 的本意只有一条 —— 规则算出来的保垫减仓不该等人 —— 但写成只看方向之后, 将来任何一条卖出 +# 候选都会被同一条捷径放过去, 包括本该交人裁决的那些。来源把「哪种减持可以不等人」写明白, +# 不再让方向替来源做这个决定。 +# +# 要说清的边界: 决策系统高置信风控卖出的自动清仓**不走这条路** (signal_service 直接落卖出 +# 指令), 用户命令驱动的清仓也不走 (命令服务 → 方案生成器 → 执行器)。所以来源这一项管的 +# 只有动作引擎自己产出的候选, 以及将来接进提议分流的新来源。 +SRC_ENGINE = "engine" # 动作引擎按规则算出来的 (FILL / ADD / DCA / TRIM / OPEN) +SRC_RESEARCH_WEAK = "research_weak" # 研究证据走弱触发的减持 —— 必定交人裁决, 绝不自动卖出 +SRC_TARGET_PRICE = "target_price" # 用户设的目标价到价 —— 必定交人裁决, 绝不自动卖出 + +WHY_RESEARCH_WEAK_CONFIRM = "研究证据走弱触发的减持,必须交人裁决,不自动卖出" +WHY_TARGET_PRICE_CONFIRM = "你设的目标价到价,卖不卖由你决定,系统不自动卖出" + +# 必定强制入人工队列的来源: {来源: 交给人的原因}。 +# 写成一份名单而不是一个 if, 是为了将来再出现「不许自动执行」的来源时加一行就够, +# 提议分流那一段一个字都不用改。 +FORCE_QUEUE_SOURCES = {SRC_RESEARCH_WEAK: WHY_RESEARCH_WEAK_CONFIRM, + SRC_TARGET_PRICE: WHY_TARGET_PRICE_CONFIRM} + + +def source_confirm_why(source): + """这个来源要不要强制交人裁决: 要则回一句原因, 不要回 None。 + + 认不出的来源回 None (走原有的方向与档位判定) —— 与判决值那条「认不出一律交人」相反, + 是因为两者问的不是同一件事: 判决是上游对这只票的结论, 词表变了说明我们看不懂它的意思; + 而来源是本系统内部给候选打的标记, 没打标记的就是动作引擎自己那几类老动作, 行为必须 + 与从前逐字一致。新来源要不要强制交人, 由加它的人写进上面那份名单。 + """ + return FORCE_QUEUE_SOURCES.get(str(source or "").strip()) or None + # 上游选股系统给每张候选卡的判决 (2026-09-02 起随计划接口每行下发; 旧版计划没有这个字段)。 # 候选 门槛全过、无硬风险、确认线在 —— 走现有的自动流程 (规则闸 → 研判闸 → 档位分流) # 关注 门槛全过、无硬风险、但确认线缺失或陈旧 —— 这是上游说「无法判断」, 强制交人裁决 @@ -82,10 +126,26 @@ def _f(v, d=0.0): return d -def _cand(p, action, side, qty, reason, hard, *, confirm=False): +def _cand(p, action, side, qty, reason, hard, *, confirm=False, source=SRC_ENGINE): return {"ts_code": p["ts_code"], "action": action, "side": side, "qty": int(qty), "reason": reason, "hard_numbers": hard, "needs_user_confirm": bool(confirm), - "judge_required": action in JUDGE_ACTIONS} + "judge_required": action in JUDGE_ACTIONS, "source": source} + + +def reduce_on_weak_research(ts_code, *, qty, reason, hard_numbers=None, action=A_TRIM): + """研究证据走弱触发的减持候选 —— **预留通道, 目前还没有上游在产出它** (2026-09-03)。 + + 为什么空着也要先建: 减持这条路从前是「方向是卖就自动执行」, 研究证据走弱这类减持哪天 + 接上来, 默认结果就是自动卖出; 而设计里这类减持必须交人裁决。通道与「必定入人工队列」 + 的判定先钉在这里并由单测守住, 将来接上游时不必再想一遍, 也不会有人凭印象接错。 + + 产出的候选与四类自主动作同结构, 可以直接送 proposal_service._route_one。它带三样东西: + 来源标记 (分流据此强制入队)、needs_user_confirm (与深档补仓同一个字段)、交人的原因。 + """ + c = _cand({"ts_code": ts_code}, action, SELL, qty, reason, dict(hard_numbers or {}), + confirm=True, source=SRC_RESEARCH_WEAK) + c["confirm_why"] = WHY_RESEARCH_WEAK_CONFIRM + return c def batch_amount(p: dict, params: dict, idx: int) -> float: @@ -223,7 +283,55 @@ def eval_trim(p: dict, params: dict, mkt: dict = None): "price": p.get("price")}) -EVALUATORS = ((A_TRIM, eval_trim), (A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca)) +def eval_target(p: dict, params: dict = None, mkt: dict = None): + """目标价到价: 现价摸到你设的目标价 —— 提议把这只票清掉, 交你拍板, 系统不自动卖。 + + 这是「设定某股目标价」这条命令的落地处 (2026-09-03 接通)。在此之前这条命令只把价格 + 存进命令表, 全库没有任何地方读它: 页面点得下去、状态会变成生效中、列表里看得见, + 而到价那天什么都不会发生 —— 设计里写的「触发即生成止盈提议」一直是空的。 + + 三处口径是照抄现成的, 没有新阈值: + 触发 现价 ≥ 目标价。目标价是你自己填的数, 不是系统算的, 所以不设缓冲、不做平滑, + 也**不随收益读数回调** —— 系统在这件事上没有可调的旋钮。 + 数量 全部持仓 (零股一并), 与「清仓某股」命令的 planner.plan_exit_stock 同一口径; + 动作名也用同一个 EXIT, 于是执行器的清仓择时 (允许零股一次性卖出) 直接复用。 + 落点 与保垫减仓同层 —— 同样是动作引擎产出候选, 走 规则闸 → 提议分流。区别只有 + 一条: 保垫减仓是纯规则算的、可以自动执行, 而目标价是**你的意思**, 到价只 + 意味着「该问你了」, 不意味着「替你卖」。所以带上强制交人的来源标记, 分流 + 那一步无论档位是不是 full 都必定落进等你拍板的队列。 + + 没有现价 (停牌、行情断了) 时不产出: 扫描入口已经把 price_ok 为假的票整只挡掉了, + 这里再看一眼 price 本身, 是因为本函数也可能被单独调用。「拿不到价」绝不能折成「到价了」。 + """ + target = _f(p.get("target_price")) + if target <= 0: + return None # 没设目标价, 这条动作根本不存在 + if p.get("price_ok") is False: + return None # 价是拿摊薄成本顶的, 不是行情 + price = _f(p.get("price")) + if price <= 0: + return None # 取不到现价, 宁可不动 + if price < target: + return None # 还没到价 + qty = int(p.get("total_qty") or 0) + if qty <= 0: + return None + c = _cand(p, A_EXIT, SELL, qty, + f"目标价到价: 现价 {price} 已到你设的目标价 {target}, " + f"拟清仓 {qty} 股 (含零股); 卖不卖由你拍板, 系统不自动卖", + {"target_price": target, "price": price, "total_qty": qty, + "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) + c["confirm_why"] = WHY_TARGET_PRICE_CONFIRM + return c + + +EVALUATORS = ((A_TRIM, eval_trim), (A_EXIT, eval_target), + (A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca)) + +# 减持方向的动作 —— 冻结只禁增持, 这几类照评 (与规则闸 _check_sell 同一口径)。 +SELL_SIDE_ACTIONS = (A_TRIM, A_EXIT) # ================================================================ 跳过原因 @@ -251,16 +359,21 @@ def skip_why(skip, key): # ================================================================ 扫描入口 def scan(*, positions: list, params: dict, market: dict, skip=None, - strategy_codes=None) -> dict: + strategy_codes=None, stock_params=None) -> dict: """扫描全部持仓, 产出候选动作。 - positions: portfolio.positions_view()["held"] 的口径 - market: {ts_code: {ma5, high5, tdays_since_open, tdays_since_last_add}} - skip: 不再评估的 (代码, 动作) —— 字典时值是跳过原因, 集合时退回兜底话 + positions: portfolio.positions_view()["held"] 的口径 + market: {ts_code: {ma5, high5, tdays_since_open, tdays_since_last_add}} + skip: 不再评估的 (代码, 动作) —— 字典时值是跳过原因, 集合时退回兜底话 + stock_params: 个股参数命令的当前值 {ts_code: {target_price, stop_price, black, ...}}, + 由 command_service.effective_stock_params() 给出。**事实源是命令表**, + 不是持仓行上的投影 —— 投影每天盘前会被参考位取数覆盖, 拿它做判断会 + 在第二天早上悄悄失效。不传时目标价这条动作不评估, 其余四类行为不变。 返回 {"candidates": [...], "skipped": [...]} """ skip = skip or {} strategy_codes = strategy_codes or set() + stock_params = stock_params or {} out, skipped = [], [] for p in positions or []: code = p.get("ts_code") @@ -282,6 +395,11 @@ def scan(*, positions: list, params: dict, market: dict, skip=None, "why": "取不到现价 (price 是拿摊薄成本顶的), 本轮不评估该票"}) continue mkt = (market or {}).get(code) or {} + # 个股参数命令的当前值并进这一票的快照, 供目标价那条动作读。只加不覆盖既有字段, + # 且是**这一轮的副本**, 不动调用方传进来的持仓行。 + sp = stock_params.get(code) or {} + if sp: + p = {**p, "target_price": sp.get("target_price"), "stop_price": sp.get("stop_price")} frozen = (p.get("frozen_reason") or "NONE") != "NONE" cands_this = [] for action, fn in EVALUATORS: @@ -290,7 +408,7 @@ def scan(*, positions: list, params: dict, market: dict, skip=None, skipped.append({"ts_code": code, "action": action, "why": why}) continue # 冻结只禁增持, 减仓照评 (与规则闸同一口径, 这里先剪枝少算一遍) - if frozen and action != A_TRIM: + if frozen and action not in SELL_SIDE_ACTIONS: skipped.append({"ts_code": code, "action": action, "why": f"该股 {p['frozen_reason']}, 禁增持"}) continue @@ -304,14 +422,33 @@ def scan(*, positions: list, params: dict, market: dict, skip=None, cands_this.append(c) # 同一只票同轮买卖互斥 (2026-08-28 审查修): 保垫减仓的峰值是**全时段**只增不减, # 加仓判据看的是近 5 日窗口, 两套时间基准可以同时成立 —— 一轮里对同一只票 - # 一边 TRIM 锁盈一边 ADD 加仓, 自动对倒空耗手续费。触发 TRIM 时买入侧让路 - # (先落袋为安, 保守方向优先), 买入条件真成立的话下一轮 TRIM 不触发时自然会来。 - if any(c["action"] == A_TRIM for c in cands_this): + # 一边 TRIM 锁盈一边 ADD 加仓, 自动对倒空耗手续费。触发减持时买入侧让路 + # (先落袋为安, 保守方向优先), 买入条件真成立的话下一轮不触发减持时自然会来。 + # 2026-09-03: 判据从「有没有 TRIM」放宽成「有没有减持」—— 目标价到价那条也是减持, + # 一边提议清仓一边提议加仓同样是自相矛盾, 不该只有保垫减仓享受这条互斥。 + sells_this = [c["action"] for c in cands_this if c["side"] == SELL] + if sells_this: for c in cands_this: if c["side"] == BUY: skipped.append({"ts_code": code, "action": c["action"], - "why": "同轮已触发保垫减仓(TRIM), 买卖互斥, 买入侧让路"}) + "why": f"同轮已触发减持({'/'.join(sells_this)}), " + f"买卖互斥, 买入侧让路"}) cands_this = [c for c in cands_this if c["side"] != BUY] + # 同一轮里只留一条减持 (2026-09-03 修)。两条减持同时留下会让人的操作落空: + # 保垫减仓不需要确认、当场就卖掉一部分, 而到价清仓要等人拍板, 它记的股数是扫描 + # 那一刻的持仓; 等人第二天点采纳, 持仓已经被前一条卖少了, 落指令时数量超过实际 + # 持仓, 卖出前的检查会整条驳回 —— 人点了「清掉」, 结果一股没卖, 只在评审账本上 + # 留下一条拒绝。留哪一条按「人的意思优先」: 用户自己设的目标价到价排在系统按规则 + # 算出来的减仓前面。被让路的那条记进跳过原因, 下一轮条件仍成立时自然会再来。 + sells_now = [c for c in cands_this if c["side"] == SELL] + if len(sells_now) > 1: + keep = min(sells_now, key=lambda c: _SELL_PRIORITY.get(c["action"], 99)) + for c in sells_now: + if c is not keep: + skipped.append({"ts_code": code, "action": c["action"], + "why": f"同轮已有优先级更高的减持({keep['action']}), " + f"一轮只发一条减持, 本条让路"}) + cands_this = [c for c in cands_this if c["side"] != SELL or c is keep] out.extend(cands_this) return {"candidates": out, "skipped": skipped} diff --git a/app/core/command_spec.py b/app/core/command_spec.py index 22d27e8..8089b0e 100644 --- a/app/core/command_spec.py +++ b/app/core/command_spec.py @@ -212,12 +212,22 @@ SPECS = { "cls": CLS_PARAM, "label": "设定某股止损价", "group": "C", "scope": "stock", "fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)}, "projection": {"stop_ref": "@price", "ref_source": "user"}, - "note": "覆盖系统参考位; 触发即生成卖出方案", + # 2026-09-03 改口径 (原文写的是「触发即生成卖出方案」, 而实现里从来没有这一步): + # 止损价现在参与规则检查, 但**只披露不拦截、也不自动卖**。跌破时每条买入指令的 + # 评审账本上会带一句 STOP_BREACHED, 页面参考位列也显示它。要真卖请下清仓/减持命令。 + "note": "覆盖系统参考位; 跌破只在合规检查里告警留痕, 不自动卖出", }, "SET_TARGET_PRICE": { "cls": CLS_PARAM, "label": "设定某股目标价", "group": "C", "scope": "stock", "fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)}, - "note": "触发即生成止盈提议 (事实源为本命令最新 EFFECTIVE 记录)", + # 2026-09-03 接通: 动作引擎每轮扫描读本命令最新 EFFECTIVE 记录, 现价到价即产出一条 + # 清仓提议 (动作 EXIT, 全部持仓), 且**必定进「等我拍板」队列** —— 自主档位是 full + # 也不自动卖。落点见 action_engine.eval_target。 + # 取消的办法就是再下一次本命令、价格填 0 (字段下限是 0, 页面也接受)。填 0 之后 + # 目标价当作没设过, 不再产出到价提议。写在这里是因为没有单独的清除命令, 不写清楚 + # 的话被驳回的到价提议每天都会回到「等我拍板」, 而人找不到关掉它的办法。 + "note": "到价产出清仓提议交你拍板, 不自动卖; 再下一次本命令、价格填 0 即取消 " + "(事实源为本命令最新 EFFECTIVE 记录)", }, "BLACKLIST_ADD": { "cls": CLS_PARAM, "label": "加入黑名单(永不买入)", "group": "C", "scope": "stock", diff --git a/app/core/rule_gate.py b/app/core/rule_gate.py index 5c1c852..6923bcb 100644 --- a/app/core/rule_gate.py +++ b/app/core/rule_gate.py @@ -52,8 +52,12 @@ def check(*, side: str, action: str, qty: int, price: float, ctx: dict) -> dict: caps check_all_caps 所需组合上下文 (仅买入用) day {price, vwap, ma5, day_chg_from_open, halted, limit_up, limit_down} params {no_chase_ma5, buy_halt_dayup, sector_source_ready} - flags {buy_halt, exec_halt, brake_active, blacklisted, is_command, y_signal} - y_signal 是决策系统昨夜结论对该股的定性, 由择时应答带回 (可能为 None) + flags {buy_halt, exec_halt, brake_active, blacklisted, is_command, y_signal, + stop_price} + y_signal 是决策系统昨夜结论对该股的定性, 由择时应答带回 (可能为 None) + stop_price 是用户「设定某股止损价」命令里的价 (可能为 None), 事实源是 + 命令表 —— 持仓行上的 stop_ref 每天盘前会被参考位取数覆盖, + 拿投影做判断会在第二天早上悄悄失效 """ pos = ctx.get("position") or {} day = ctx.get("day") or {} @@ -84,6 +88,8 @@ def check(*, side: str, action: str, qty: int, price: float, ctx: dict) -> dict: if flg.get("exec_halt"): failed.append("EXEC_HALT: 全局暂停执行 (休假模式) 生效中") + _check_user_stop(warns, side, price, flg, hard) + ml = lot_of(ctx.get("ts_code")) # 最小申报数量 (科创板 200, 其余 100) if side == SELL: _check_sell(failed, warns, qty, total_qty, avail_qty, day, ml) @@ -95,6 +101,34 @@ def check(*, side: str, action: str, qty: int, price: float, ctx: dict) -> dict: return {"passed": not failed, "failed": failed, "warnings": warns, "hard_numbers": hard} +def _check_user_stop(warns, side, price, flg, hard): + """用户设的止损价: **只披露不拦截** (2026-09-03 接通「设定某股止损价」这条命令)。 + + 在此之前这条命令的价格只存进命令表, 全库没有一处读它 —— 页面能设、状态是生效中, + 而跌破那天什么都不会发生。这里把它接进规则闸的告警层, 与既有的敞口披露、 + LIMIT_DOWN、CASH_ESTIMATED 同一层: 进 warnings, 同时无条件写进 hard_numbers, + 于是每一条指令的评审账本上都查得到「当时你设的止损价是多少、破没破」。 + + 为什么只告警不拦: 止损价是**你的意思**, 不是一道硬约束。真按它自动卖出属于 + 「系统替你决定卖」, 不在纪律允许的范围内; 而拿它去拦别的指令 (比如禁止在止损位 + 之下买入) 是凭空多出一条没人拍过板的规则。所以这里只做一件事 —— 让它被看见。 + 告警只在增持方向发一句: 在自己设的止损位之下还加仓, 是最该被人看见的一种情形; + 减持方向本来就是止损位该做的事, 不必再提示一遍。 + + **没有告警不等于没跌破** —— 取不到现价 (price<=0) 或没设止损价时这里什么都不做, + hard_numbers 里 stop_price 会是 None, 与「设了且没破」在账本上分得开。 + """ + stop = _num(flg.get("stop_price")) + hard["stop_price"] = stop or None + if stop <= 0 or price <= 0: + return + breached = price <= stop + hard["stop_breached"] = breached + if breached and side == BUY: + warns.append(f"STOP_BREACHED: 现价 {price} 已跌破你设的止损价 {stop}, 本单仍是买入 " + f"—— 只提示不拦截; 止损价也不会自动挂单卖出") + + def _check_sell(failed, warns, qty, total_qty, avail_qty, day, min_lot: int = LOT): """减持方向: 冻结/刹车/上限一概不拦, 只看「卖得出去吗」。""" if qty > total_qty: diff --git a/app/services/executor.py b/app/services/executor.py index 22116f8..4e0b9c2 100644 --- a/app/services/executor.py +++ b/app/services/executor.py @@ -281,6 +281,9 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict: "flags": {"buy_halt": prm["buy_halt"], "exec_halt": prm["exec_halt"], "brake_active": brake_active, "blacklisted": bool(stock_params.get(code, {}).get("black")), + # 用户设的止损价, 与黑名单同一份事实源 (命令表)。规则闸拿它 + # 只告警不拦截, 告警随 hard_numbers 落进评审账本。 + "stop_price": stock_params.get(code, {}).get("stop_price"), "is_command": bool(prog.get("is_command")), # 决策系统昨夜对该股的定性, 由择时应答带回 (退实现B 时为 None) "y_signal": d.get("y_signal")}}) diff --git a/app/services/param_store.py b/app/services/param_store.py index b85bd17..7d4b0bd 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -90,6 +90,46 @@ FAIL_CLOSED = { "PMS_AUTO_STRATEGY_ENABLED": False, } +# ================================================================================ +# 页面能改、但代码里没有任何地方读它的参数 (2026-09-03 全库清点) +# ================================================================================ +# 清点方法, 想复核照做即可: 拿 DESC / RUNTIME_EXTRA / settings 里 PMS_ 开头、页面能改的 +# 全部键名 (当日 179 个), 逐个在 app/ 下做整词检索, 并把两类**不算读取**的文本剔掉 —— +# 一是本文件里 DESC、RUNTIME_EXTRA、SECRET_KEYS、FAIL_CLOSED、INFRA_PREFIX 这几处声明 +# (它们只是登记), 二是全库的注释与文档字符串 (说到不等于读到; PMS_T0_CLOSE_TIME 当初就是 +# 靠一句文档字符串把自己藏了一年)。剩下零命中的就是「能改但不生效」。 +# 只在 sizing_params() 里出现的键还要再跟一层短名 (它把 PMS_XXX 折成 batch_split 这类短名 +# 交给纯逻辑层), 当日三个 (分批比例、一手合并、清弱票天数) 都跟到了真实读取处, 不算在内。 +# 这份清点由 scripts/test_batch10_units.py 的 [P] 组守着 —— 名单变了单测就红, 逼人来这里 +# 记一笔, 不让新的「改了不生效」再悄悄长出来。 +# +# 一、参数表里零读取处的四个 (页面可改, 改了什么都不会发生): +# PMS_REPLAY_INTERVAL_MIN 说明写「成交回放间隔 (分钟)」, 而回放的调度位在 scheduler.py +# 里写死是 crontab(minute="*") 每分钟一跳, 这个数根本没人读。 +# PMS_T0_CLOSE_TIME 说明写「T仓强制平回时点」, 同一种毛病: 调度位写死在 +# crontab(hour=14, minute=50)。页面把它改成 14:30 不会有任何 +# 效果, 而这是一条与过夜风险直接相关的时点。 +# 以上两个要么让调度位读它, 要么把它连同说明一起摘掉 —— 一条 +# 时点参数摆在页面上而调度位不认, 比没有这条参数更危险。 +# PMS_RISK_WARN_ENTRY 单笔敞口告警线。sizer.risk_warnings() / risk_exposure() 这对 +# PMS_RISK_WARN_PORTFOLIO 函数写完之后**从来没有被 app/ 里任何地方调用过** (只有单测在 +# 调), 于是这两条披露线是两个纯粹的摆设。风险披露要不要真做, +# 是一件要拍板的事, 不是改个数就能生效的事。 +# +# 二、个股参数命令里同一种毛病 (不在本表, 但病因一样, 一并记在这里): +# 设定某股目标价 SET_TARGET_PRICE 2026-09-03 已接通 —— 动作引擎 eval_target 到价产出 +# 清仓提议, 必定交人拍板。接通前它只存进命令表没人读。 +# 设定某股止损价 SET_STOP_PRICE 2026-09-03 已接进规则闸的告警层 (只披露不拦截, +# 不自动卖)。但它投影到持仓行的 stop_ref 每天盘前会被 +# ledger_service.premarket() 的参考位取数覆盖掉 —— +# 页面参考位列第二天显示的就不再是你设的那个价了。 +# 所以判断一律读命令表 (事实源), 别读投影。这处覆盖 +# 本身还没修, 见交接里的遗留问题。 +# 做T授权 T0_ENABLE 只把 t0_enabled / t0_ratio 投影到持仓行给页面显示, +# 没有任何代码把它当成「可以做T」的授权来检查 (真正的 +# 做T由单独挂的交易方案驱动)。要不要把它变成硬授权, +# 需要拍板, 本次不动。 +# # 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护) DESC = { "PMS_TOTAL_SCALE": "总操作规模 (元) —— 所有百分比约束的分母", diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index 35b3086..6630c82 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -8,7 +8,10 @@ off 不扫描 两条无条件覆盖档位的规矩: - * **减持方向不设确认门槛** —— TRIM 保垫减仓属纯规则自动执行, 任何档位都直接落指令。 + * **规则算出来的减持不设确认门槛** —— TRIM 保垫减仓属纯规则自动执行, 任何档位都直接落 + 指令。但这一条覆盖的是**档位**, 不覆盖「强制入人工队列」这个标记 (2026-09-03 修): 带了 + 这个标记的减持一律交人, 方向是卖也不例外。哪种减持带标记由候选的来源说了算, 见 + `action_engine.source_confirm_why` 与 `_route_one` 里那段说明。 * **−15% 及更深的补仓永远需用户确认** —— 即便档位是 full, 也强制入队。 研判闸不可用时 (决策系统未接通/超时), 按设计自动降级为 propose_only + ERROR 告警, @@ -77,6 +80,9 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: try: view = portfolio.positions_view() params = _scan_params(view) + # 个股参数命令的当前值 (黑名单、目标价、止损价…)。取数提到扫描之前, 因为动作引擎 + # 现在要用它评「目标价到价」那条动作; 后面分流与规则闸用的是同一份, 不重复查。 + stock_params = command_service.effective_stock_params() mkt = _market_ctx(view["held"], now) params["_mkt"] = mkt # 规则闸要用同一份 MA5, 不再重取 # 跳过三类: ①已有在途提议或指令的 ②今天已被规则闸拒过的 ③今天已被研判闸驳回的新建仓。 @@ -98,7 +104,7 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: except Exception: strategy_codes = set() scanned = ae.scan(positions=view["held"], params=params, market=mkt, skip=skip, - strategy_codes=strategy_codes) + strategy_codes=strategy_codes, stock_params=stock_params) except Exception as e: logger.exception("提议扫描失败") return {**out, "ok": False, "errors": [f"扫描失败: {type(e).__name__}: {e}"]} @@ -119,7 +125,6 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: out["candidates"] = len(scanned["candidates"]) out["skipped"].extend(scanned["skipped"]) - stock_params = command_service.effective_stock_params() brake_active = td.ymd() < param_store.get_int("PMS_BRAKE_UNTIL", 0) # ---- 新建仓: 候选池那一路 (取不到候选池不影响上面四类, 反之亦然) ---- @@ -435,6 +440,9 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out, "flags": {"buy_halt": params.get("buy_halt"), "exec_halt": params.get("exec_halt"), "brake_active": brake_active, "blacklisted": bool(stock_params.get(code, {}).get("black")), + # 用户设的止损价, 与黑名单同一份事实源 (命令表)。规则闸拿它只告警 + # 不拦截, 见 rule_gate 里那段说明。 + "stop_price": stock_params.get(code, {}).get("stop_price"), "is_command": False}}) if not gate["passed"]: out["rejected"].append({"ts_code": code, "action": action, "by": "rule", @@ -474,11 +482,30 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out, # ---- 三级: 按档位分流 ---- # 新建仓走自己的档位 (PMS_OPEN_AUTONOMY), 不跟随全局 —— 「新建仓要不要人点头」和 # 「加仓要不要人点头」是两个不同的决定。全局 off 已经在 scan_and_route 入口拦掉了, - # 走到这里说明总闸是开的。三条无条件覆盖档位的规矩对新建仓一样有效: 减持自动、 - # 深档补仓强制确认、研判不可用一律入队。 + # 走到这里说明总闸是开的。三条无条件覆盖档位的规矩对新建仓一样有效: 规则算出来的减持 + # 自动执行、深档补仓强制确认、研判不可用一律入队。 autonomy = (out.get("open_autonomy") or AUTONOMY_FULL) if is_open else out["autonomy"] - force_queue = bool(c.get("needs_user_confirm")) or verdict.get("degraded") - auto_exec = (side == "sell") or (autonomy == AUTONOMY_FULL and not force_queue) + # 强制入人工队列是**一票否决**, 排在方向与档位前面 (2026-09-03 修)。原来这一行是 + # auto_exec = (side == "sell") or (autonomy == AUTONOMY_FULL and not force_queue) + # 卖出方向在或运算的左边, 把 force_queue 整个短路了 —— 方向是卖, 「强制入人工队列」 + # 这个标记就不起作用。今天还没出事, 是因为走到这里的卖出候选只有保垫减仓一种, 它既不 + # 强制确认也不送研判, force_queue 恒为假; 但设计里明确要求「研究证据走弱触发的减持必须 + # 交人裁决、绝不自动卖」, 那类减持一旦接上来, 结果会是自动卖出。 + # + # 为什么选一票否决而不是按来源开白名单: 白名单要求「谁可以自动卖」有一份完整清单, 而 + # 这条路上真正需要自动卖的两条 —— 决策系统高置信风控卖出的自动止损、用户命令驱动的清仓 + # —— 根本不经过提议分流 (前者是 signal_service 直接落卖出指令, 后者是命令服务 → 方案 + # 生成器 → 执行器), 白名单在这里会是一份空转的清单。而 force_queue 这个名字本来就承诺了 + # 「强制入人工队列」, 让它对所有方向都算数, 是把这个名字兑现, 不是新加一条规矩。 + # 来源标记仍然要有, 但它的职责是让新来源能声明自己必须交人 (见 ae.source_confirm_why), + # 不是去给已有的自动止损发通行证。 + # + # 保留下来的行为: 规则算出来的保垫减仓照旧自动执行 (它的 force_queue 是假), 风控高置信 + # 卖出与命令清仓两条路一个字没碰。 + src_why = ae.source_confirm_why(c.get("source")) + force_queue = (bool(c.get("needs_user_confirm")) or bool(src_why) + or bool(verdict.get("degraded"))) + auto_exec = (not force_queue) and (side == "sell" or autonomy == AUTONOMY_FULL) # 自动执行开关 (2026-09-03, PMS_OPEN_AUTO_EXEC_ON_VERDICT): 新建仓档位是 propose_only 时, # 「判决候选 + 决策系统研判真回了通过 + 规则闸通过 (走到这里就是通过了) + 上游风险列表 # 为空」四条齐, 这一条按 full 处理。强制入队的 (关注 / 深档补仓 / 研判不可用) 永远不走。 @@ -505,16 +532,18 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out, verdict="PASS", price_at=price, hard_numbers=c["hard_numbers"], ref_id=iid, reason=reason[:500]) out["executed"].append({**_brief(c), "instruction_id": iid, - "why": ("减持方向自动执行" if side == "sell" + "why": ("减持自动执行 (规则触发, 来源没有要求交人裁决)" + if side == "sell" else (auto_why or ("新建仓档位 full" if is_open else "档位 full")))}) else: pid = _make_proposal(c, price, verdict) - # 强制确认的原因: 候选自带的 (关注判决等, 见 action_engine.verdict_confirm_why) - # 优先, 没有就是深档补仓那条老规矩。 - why = ((c.get("confirm_why") or "深档补仓强制确认") if c.get("needs_user_confirm") - else ("研判不可用, 降级人工确认" if verdict.get("degraded") - else ("新建仓档位 propose_only" if is_open else "档位 propose_only"))) + # 交人的原因按从具体到笼统取: 候选自带的 (关注判决等, 见 action_engine.verdict_confirm_why) + # → 来源强制的 (研究证据走弱那类减持) → 深档补仓那条老规矩 → 研判不可用 → 档位。 + why = (c.get("confirm_why") or src_why + or ("深档补仓强制确认" if c.get("needs_user_confirm") else None) + or ("研判不可用, 降级人工确认" if verdict.get("degraded") else None) + or ("新建仓档位 propose_only" if is_open else "档位 propose_only")) out["queued"].append({**_brief(c), "proposal_id": pid, "why": why}) @@ -647,6 +676,9 @@ def _make_proposal(c, price, verdict) -> str: pid = f"PRP_{td.ymd()}_{c['ts_code'].replace('.', '')}_{c['action']}" hn = {**(c.get("hard_numbers") or {}), "price": price, "reason": c["reason"], "needs_user_confirm": c.get("needs_user_confirm", False), + # 候选来源 (2026-09-03): 人在「等我拍板」里要看得出这条减持是规则算的还是研究 + # 证据走弱推来的 —— 两者该不该点头是两回事。没有来源的按动作引擎自身处理。 + "source": c.get("source") or ae.SRC_ENGINE, # 研判应答的结论与置信度 (2026-09-03): 人裁决时要看得见决策系统怎么说、有多确定。 # judge_reason 另有一列, 这两项进硬数字是为了随账本走 (采纳/驳回时原样落账)。 "judge_verdict": verdict.get("verdict"), "judge_conf": verdict.get("confidence")} diff --git a/app/web/static/index.html b/app/web/static/index.html index 4a1fe34..d449852 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -2392,12 +2392,14 @@ createApp({ } async function actStop(row) { const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—') - + '。跌破这个价就挂单卖出,填多少元?', '设止损 ' + nm(row.ts_code)); + + '。止损价只做提醒:跌破了会在合规检查里留一条记录,系统不会替你挂单卖出。' + + '真要卖请下清仓或减持命令。填多少元?', '设止损 ' + nm(row.ts_code)); if (v != null) quickCmd('SET_STOP_PRICE', { ts_code: row.ts_code, price: v }); } async function actTargetPrice(row) { const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—') - + '。目标价(元,到价生成止盈提议)', '设目标价 ' + nm(row.ts_code)); + + '。目标价(元):到价后生成一条清仓提议放进「等我拍板」,卖不卖由你点头,' + + '系统不自动卖。不想要了就再设一次、填 0 即取消。', '设目标价 ' + nm(row.ts_code)); if (v != null) quickCmd('SET_TARGET_PRICE', { ts_code: row.ts_code, price: v }); } function actFreeze(row) { diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 7a83e39..905dc05 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -7,15 +7,18 @@ 包含 (例数按 2026-09-03 实跑校正; 此前写的 591 是过期数字, 当时实跑已是 608): test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例) test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例) - test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (25 例) - test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 例) + test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 / 用户止损价只披露不拦截 (26 例) + test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 + + 目标价到价产出清仓候选 (必定交人裁决) + + 同轮只发一条减持 (到价清仓优先于保垫减仓) (15 例) test_batch5_units.py 决策系统信号流解析与消化口径 (8 例) test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/弃洞/DDL/逐笔入账 (68 例) test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫/ 候选卡五键透传与截断/仅展示在候选阶段剔除 (35 例) test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例) test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 例) - test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (45 例) + test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 + + 页面能改的参数必须有人读 ([P] 组静态扫描) (47 例) test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 例) test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/ 转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/ @@ -44,8 +47,12 @@ 开关四条件与任一缺失仍入队/信号来源区分/密钥名单 含会话密钥/研判键放行与必答题/置信度进硬数字/ 页面静态守卫 (15 例) - test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (67 例) - 共 626 例 + test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/ + 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱 + 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 例) + test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) + + 目标价到价必定入队 (档位 full 也不自动卖) (69 例) + 共 649 例 任一子集失败即整体失败 (退出码 1)。 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): @@ -64,6 +71,12 @@ 密钥名单 scripts/test_batch6_units.py 用例「密钥不进 ParamStore (协议 §10.1.1)」(约第 642 行) 与 scripts/test_batch20_units.py 用例「密钥名单·会话密钥」—— 新增密钥类配置要同时进 param_store.SECRET_KEYS 与这两处断言 + 死参数名单 scripts/test_batch10_units.py 的 KNOWN_DEAD ([P] 组) 与 param_store.py 顶部 + 那段清点 —— 「页面能改而全库没人读」的参数只能同时改这两处。接通了、摘掉了、 + 或者又添了一个摆设, 都要在这里过一遍手 + 动作求值器 scripts/test_batch12_units.py 用例「回归·既有四类动作的判据与研判范围没被 + 动过」—— action_engine.EVALUATORS 的名单与次序钉在那里 (次序有意义: 减持排 + 在买入前面, 同轮买卖互斥才让得了路) """ import os import subprocess @@ -78,6 +91,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py", "test_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py", "test_batch16_units.py", "test_batch17_units.py", "test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py", + "test_batch21_units.py", "test_wiring.py"] diff --git a/scripts/test_batch10_units.py b/scripts/test_batch10_units.py index 07ebb8a..4bdfc01 100644 --- a/scripts/test_batch10_units.py +++ b/scripts/test_batch10_units.py @@ -960,6 +960,125 @@ def _(): qmt_repo.get_order = orig +# ================================================================ +# [P] 页面能改、但代码里没人读的参数 (静态扫描) +# ================================================================ +# 与 [A] 组是同一种病的两张脸: [A] 管「写了没人接住」, [P] 管「改了没人读」。 +# 参数摆在设置页上、改完提示已保存、值也真进了表 —— 而全库没有一处读它。于是 +# 「把 T 仓强制平回改到 14:30」这件事什么都不会发生, 页面上还看不出来。 +# +# 判据: 键名在 app/ 下**代码里**整词出现过一次就算有读取处。两类文本不算读取 —— +# ① param_store 里 DESC / RUNTIME_EXTRA / SECRET_KEYS / FAIL_CLOSED / INFRA_PREFIX +# 这几处声明 (它们是登记, 不是读取); +# ② 全库的注释与文档字符串 (说到不等于读到 —— PMS_T0_CLOSE_TIME 当初就是靠 +# strategy_runner 里一句文档字符串把自己藏住的)。 +# 已知的死参数钉在 KNOWN_DEAD 里: 名单一变单测就红, 逼人去 param_store 那段清点里 +# 记一笔 —— 是接通了、是摘掉了、还是又新添了一个摆设。 +KNOWN_DEAD = { + "PMS_REPLAY_INTERVAL_MIN", # 回放调度位写死 crontab(minute="*") + "PMS_T0_CLOSE_TIME", # 平回调度位写死 crontab(hour=14, minute=50) + "PMS_RISK_WARN_ENTRY", # sizer.risk_warnings() 从没被 app/ 调用过 + "PMS_RISK_WARN_PORTFOLIO", # 同上 +} +_DECL_NAMES = ("DESC", "RUNTIME_EXTRA", "SECRET_KEYS", "FAIL_CLOSED", "INFRA_PREFIX") + + +def _code_only(path, drop_param_decls=False): + """文件正文, 去掉注释与文档字符串 (非 .py 原样返回)。解析不了就退回原文 —— 宁可少报。""" + import io + import tokenize + src = open(path, encoding="utf-8", errors="ignore").read() + if not path.endswith(".py"): + return src + drop = set() + try: + tree = ast.parse(src) + for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, + ast.AsyncFunctionDef)): + if ast.get_docstring(node, clean=False) is not None: + first = node.body[0] + drop.update(range(first.lineno, first.end_lineno + 1)) + if drop_param_decls: + for node in tree.body: + if (isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name) + and node.targets[0].id in _DECL_NAMES): + drop.update(range(node.lineno, node.end_lineno + 1)) + except SyntaxError: + return src + out = [] + try: + for tok in tokenize.generate_tokens(io.StringIO(src).readline): + if tok.type == tokenize.COMMENT or tok.start[0] in drop: + continue + out.append(tok.string) + except (tokenize.TokenError, IndentationError): + return src + return "\n".join(out) + + +def _unread_params(): + """页面能改、而 app/ 里没有一处读它的参数键名 (排序后的列表)。""" + import re + from app.services import param_store as pstore + from config.settings import settings + ps_path = os.path.join(ROOT, "app/services/param_store.py") + + keys = set(pstore.DESC) | set(pstore.RUNTIME_EXTRA) + keys |= {n for n in type(settings).model_fields if n.startswith("PMS_")} + # 与 param_store._editable_keys() 同一把尺子: 基础设施键与密钥页面本来就改不了 + keys = {k for k in keys + if not any(k.startswith(p) for p in pstore.INFRA_PREFIX) + and k not in pstore.SECRET_KEYS} + + texts = [] + for d, _dirs, files in os.walk(os.path.join(ROOT, "app")): + for f in sorted(files): + if not f.endswith((".py", ".html", ".js")): + continue + p = os.path.join(d, f) + texts.append(_code_only(p, drop_param_decls=(p == ps_path))) + return sorted(k for k in keys + if not any(re.search(r"\b" + k + r"\b", t) for t in texts)) + + +@case("[P1] 页面能改的参数必须有人读: 死参数名单只能照着 param_store 的清点走") +def _(): + dead = set(_unread_params()) + added = sorted(dead - KNOWN_DEAD) + gone = sorted(KNOWN_DEAD - dead) + assert not added, ("这些参数页面能改, 而 app/ 里没有任何地方读它 —— 改了什么都不会发生, " + "页面上还看不出来:\n " + "\n ".join(added) + + "\n要么接通它, 要么摘掉它; 确实要留着当摆设, 就写进 param_store.py " + "顶部那段清点并同步 KNOWN_DEAD。") + assert not gone, ("这些参数已经接通了 (或者被摘掉了), 但 KNOWN_DEAD 还留着它们:\n " + + "\n ".join(gone) + + "\n请同步 KNOWN_DEAD 与 param_store.py 顶部那段清点。") + + +@case("[P2] 扫描器本身有效: 造一个只在注释/文档字符串里出现的参数, 必须算「没人读」") +def _(): + # 守住守卫。注释与文档字符串一旦被当成读取处, [P1] 会永远绿 —— 那比没有守卫更糟, + # 因为 PMS_T0_CLOSE_TIME 正是被一句文档字符串遮住的。 + import tempfile + src = ('"""模块说明里提到 PMS_FAKE_DOC_ONLY。"""\n' + "def f(param_store):\n" + ' """函数说明里提到 PMS_FAKE_DOC2。"""\n' + " # 注释里提到 PMS_FAKE_COMMENT\n" + ' return param_store.get("PMS_FAKE_REAL")\n') + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, + encoding="utf-8") as fh: + fh.write(src) + tmp = fh.name + try: + body = _code_only(tmp) + for gone in ("PMS_FAKE_DOC_ONLY", "PMS_FAKE_DOC2", "PMS_FAKE_COMMENT"): + assert gone not in body, (gone, body) + assert "PMS_FAKE_REAL" in body, body + finally: + os.unlink(tmp) + + # ================================================================ def main(): ok = fail = 0 diff --git a/scripts/test_batch12_units.py b/scripts/test_batch12_units.py index fd8c976..c5fdce8 100644 --- a/scripts/test_batch12_units.py +++ b/scripts/test_batch12_units.py @@ -362,7 +362,11 @@ def _(): def _(): assert ae.JUDGE_ACTIONS == {"FILL", "ADD", "DCA", "OPEN"}, ae.JUDGE_ACTIONS assert "TRIM" not in ae.JUDGE_ACTIONS, "减持永远不送研判" - assert [a for a, _ in ae.EVALUATORS] == ["TRIM", "ADD", "FILL", "DCA"] + # 2026-09-03 接通「设定某股目标价」后多了一条 EXIT (到价产出清仓提议)。它排在 TRIM 之后、 + # 三类买入之前 —— 顺序有意义: 同轮买卖互斥要先看到减持才让买入让路。四类老动作的相对 + # 次序与判据一个字没动, 减持照旧不送研判。 + assert [a for a, _ in ae.EVALUATORS] == ["TRIM", "EXIT", "ADD", "FILL", "DCA"] + assert "EXIT" not in ae.JUDGE_ACTIONS, "减持永远不送研判 (目标价到价也是减持)" # 持仓那条扫描的输入与产出一个字没改: 空持仓进去, 空候选出来, 不会去碰候选池 r = ae.scan(positions=[], params=params(), market={}) assert r == {"candidates": [], "skipped": []}, r diff --git a/scripts/test_batch21_units.py b/scripts/test_batch21_units.py new file mode 100644 index 0000000..75a1d21 --- /dev/null +++ b/scripts/test_batch21_units.py @@ -0,0 +1,428 @@ +# -*- 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 + + +@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() diff --git a/scripts/test_batch3_units.py b/scripts/test_batch3_units.py index 36c6653..48fd585 100644 --- a/scripts/test_batch3_units.py +++ b/scripts/test_batch3_units.py @@ -254,6 +254,35 @@ def ctx(**kw): return d +@case("规则闸·用户止损价只披露不拦截: 破位买入告警 / 卖出不吵 / 无价与未设分得开") +def _(): + # 设了止损价 9.5, 现价 9.2 已跌破 —— 买入方向出告警, 但**不进 failed**, 照样放行 + r = rg.check(side="buy", action="ADD", qty=1000, price=9.2, + ctx=ctx(flags={"stop_price": 9.5}, day={"price": 9.2})) + assert r["passed"] and r["failed"] == [], r + assert any(x.startswith("STOP_BREACHED") for x in r["warnings"]), r["warnings"] + assert r["hard_numbers"]["stop_price"] == 9.5, r["hard_numbers"] + assert r["hard_numbers"]["stop_breached"] is True, r["hard_numbers"] + # 没破位: 留痕说得出「设了且没破」, 不发告警 + r2 = rg.check(side="buy", action="ADD", qty=1000, price=10.0, + ctx=ctx(flags={"stop_price": 9.5})) + assert not any(x.startswith("STOP_BREACHED") for x in r2["warnings"]), r2["warnings"] + assert r2["hard_numbers"]["stop_breached"] is False, r2["hard_numbers"] + # 卖出方向破位不吵 —— 那本来就是止损位该做的事; 但硬数字照留 + r3 = rg.check(side="sell", action="EXIT", qty=6000, price=9.2, + ctx=ctx(flags={"stop_price": 9.5})) + assert r3["passed"] and not any(x.startswith("STOP_BREACHED") for x in r3["warnings"]), r3 + assert r3["hard_numbers"]["stop_breached"] is True, r3["hard_numbers"] + # 没设止损价 / 取不到现价: 一律不判破位, stop_price 留 None ——「没告警」不等于「没跌破」 + r4 = rg.check(side="buy", action="ADD", qty=1000, price=9.2, ctx=ctx()) + assert r4["hard_numbers"]["stop_price"] is None, r4["hard_numbers"] + assert "stop_breached" not in r4["hard_numbers"], r4["hard_numbers"] + r5 = rg.check(side="buy", action="ADD", qty=1000, price=0, + ctx=ctx(flags={"stop_price": 9.5})) + assert "stop_breached" not in r5["hard_numbers"], r5["hard_numbers"] + assert not any(x.startswith("STOP_BREACHED") for x in r5["warnings"]), r5["warnings"] + + @case("规则闸·卖出正常放行 + 硬数字留痕") def _(): r = rg.check(side="sell", action="TRIM", qty=2000, price=10.0, ctx=ctx()) diff --git a/scripts/test_batch4_units.py b/scripts/test_batch4_units.py index 7484bfe..655b972 100644 --- a/scripts/test_batch4_units.py +++ b/scripts/test_batch4_units.py @@ -141,6 +141,81 @@ def _(): PARAMS) is None # 1/3 不足一手 +# ================================================================ 目标价到价 (EXIT) +@case("目标价·到价产出清仓候选: 全部持仓 / 不送研判 / 强制交人裁决") +def _(): + c = ae.eval_target(pos(price=13.0, target_price=12.5, total_qty=6050, + avail_qty=6050, cushion_pct=0.30), PARAMS) + assert c and c["action"] == ae.A_EXIT and c["side"] == "sell", c + assert c["qty"] == 6050, c # 全部持仓, 零股一并 (与清仓命令同口径) + assert not c["judge_required"], c # 减持不送研判 + assert c["needs_user_confirm"] is True, c # 必定交人 + assert c["source"] == ae.SRC_TARGET_PRICE, c + assert c["confirm_why"] == ae.WHY_TARGET_PRICE_CONFIRM, c + assert c["hard_numbers"]["target_price"] == 12.5, c["hard_numbers"] + # 恰好等于目标价也算到价 (不设缓冲、不做平滑 —— 那个数是用户自己填的) + assert ae.eval_target(pos(price=12.5, target_price=12.5), PARAMS) is not None + + +@case("目标价·未到价 / 没设 / 取不到现价 / 空仓 一律不产出") +def _(): + assert ae.eval_target(pos(price=12.4, target_price=12.5), PARAMS) is None # 差一分不算 + assert ae.eval_target(pos(price=13.0), PARAMS) is None # 没设目标价 + assert ae.eval_target(pos(price=13.0, target_price=0), PARAMS) is None + # 取不到现价: price 是拿摊薄成本顶的假价, 绝不能折成「到价了」 + assert ae.eval_target(pos(price=13.0, target_price=12.5, price_ok=False), PARAMS) is None + assert ae.eval_target(pos(price=0, target_price=12.5), PARAMS) is None + assert ae.eval_target(pos(price=None, target_price=12.5), PARAMS) is None + assert ae.eval_target(pos(price=13.0, target_price=12.5, total_qty=0), PARAMS) is None + + +@case("扫描·目标价从个股参数命令读 (不读持仓行投影) / 冻结不挡 / 同轮买入让路") +def _(): + ps = [pos(ts_code="600000.SH", price=13.0, cushion_pct=0.30, market_value=78_000, + frozen_reason="COMMAND_HALT")] + m = {"600000.SH": mkt(ma5=12.0, high5=13.0)} + # 不传 stock_params → 这条动作根本不评估, 其余四类行为与从前一字不差 + r0 = ae.scan(positions=ps, params=PARAMS, market=m) + assert not any(c["action"] == ae.A_EXIT for c in r0["candidates"]), r0["candidates"] + # 传了命令表里的目标价 → 到价产出清仓候选; 冻结只禁增持, 挡不住它 + r = ae.scan(positions=ps, params=PARAMS, market=m, + stock_params={"600000.SH": {"target_price": 12.5}}) + acts = {(c["ts_code"], c["action"]) for c in r["candidates"]} + assert ("600000.SH", ae.A_EXIT) in acts, acts + # 同轮买卖互斥: 一边提议清仓一边提议加仓是自相矛盾, 买入侧让路 + ps2 = [pos(ts_code="000001.SZ", price=13.0, cushion_pct=0.30, market_value=78_000)] + m2 = {"000001.SZ": mkt(ma5=12.5, high5=13.0)} # 不追高那道过得去, 才真会出 ADD + r2 = ae.scan(positions=ps2, params=PARAMS, market=m2, + stock_params={"000001.SZ": {"target_price": 12.5}}) + acts2 = {c["action"] for c in r2["candidates"]} + assert ae.A_EXIT in acts2 and "ADD" not in acts2, acts2 + assert any("买卖互斥" in (s.get("why") or "") for s in r2["skipped"]), r2["skipped"] + # 传进来的持仓行不许被改写 (合并出的是本轮副本) + assert "target_price" not in ps2[0], ps2[0] + + +@case("扫描·同轮只发一条减持: 到价清仓优先于保垫减仓, 免得人点了采纳却卖不成") +def _(): + # 复现被修掉的那个场景: 峰值安全垫 20% 回落到 9% 会触发保垫减仓, 同时现价已到目标价。 + # 修之前两条会一起留下 —— 保垫减仓不用确认、当场卖掉一部分, 而到价清仓记的是扫描那一刻 + # 的全部持仓、要等人拍板; 等人第二天点采纳, 持仓已经少了, 落指令时数量超过实际持仓, + # 卖出前的检查会整条驳回, 人点了「清掉」结果一股没卖。 + 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, + stock_params={"600000.SH": {"target_price": 12.5}}) + sells = [c for c in r["candidates"] if c["side"] == ae.SELL] + assert len(sells) == 1, sells # 一轮只发一条 + assert sells[0]["action"] == ae.A_EXIT, sells # 留下的是人设的目标价那条 + assert sells[0]["qty"] == 6000, sells # 数量仍是全部持仓 + assert any("一轮只发一条减持" in (s.get("why") or "") for s in r["skipped"]), r["skipped"] + # 没设目标价时保垫减仓照旧, 老行为一个字不变 + r2 = ae.scan(positions=ps, params=PARAMS, market=m) + sells2 = [c for c in r2["candidates"] if c["side"] == ae.SELL] + assert len(sells2) == 1 and sells2[0]["action"] == ae.A_TRIM, sells2 + + # ================================================================ 扫描 @case("扫描·冻结票只评减仓 / 已在途不重复提 / 单票异常不拖垮整轮") def _(): diff --git a/scripts/test_wiring.py b/scripts/test_wiring.py index 8e4d34e..a7f7d90 100644 --- a/scripts/test_wiring.py +++ b/scripts/test_wiring.py @@ -1677,6 +1677,88 @@ def _(): judge.request = orig +def _target_fakes(price, target, **kw): + """一只持仓票 + 一条「设定某股目标价」命令。高点给得比现价高, 免得混进盈利加仓。""" + from app.services import command_service as csvc + fake = install_fakes( + prices={"600000.SH": price}, high5={"600000.SH": price * 1.5}, + params={"PMS_TOTAL_SCALE": "2000000", **(kw.get("params") or {})}, + positions=[{"ts_code": "600000.SH", "total_qty": 6050, "avail_qty": 6050, + "base_qty": 6000, "avg_cost": 10.0, "cushion_peak": 0.0, + "target_pct": 0.06}]) + restore = _stub_sp(csvc, {"600000.SH": {"target_price": target}} if target else {}) + return fake, restore + + +@case("自主提议·目标价到价: 必定入队等人拍板, 档位 full 也绝不自动卖") +def _(): + from app.services import proposal_service as ps + # 档位 full = 「闸门与研判通过即执行」, 而目标价这条必须无视档位入队 + fake, restore = _target_fakes(13.0, 12.5, params={"PMS_AUTONOMY": "full"}) + try: + r = ps.scan_and_route() + finally: + restore() + assert r["ok"], r + assert not any(x["action"] == "EXIT" for x in r["executed"]), r["executed"] + assert not any(i["action"] == "EXIT" for i in fake.instructions.values()), fake.instructions + qd = {(x["ts_code"], x["action"]) for x in r["queued"]} + assert ("600000.SH", "EXIT") in qd, r + q = [x for x in r["queued"] if x["action"] == "EXIT"][0] + assert "目标价" in (q["why"] or "") and "不自动卖" in (q["why"] or ""), q + prop = [p for p in fake.proposals.values() if p["action"] == "EXIT"] + assert prop and prop[0]["qty"] == 6050, prop # 全部持仓, 零股一并 (清仓口径) + hn = prop[0]["hard_numbers"] + assert hn["target_price"] == 12.5 and hn["price"] == 13.0, hn + assert hn["needs_user_confirm"] is True, hn + # 采纳时按动作反推方向那一步认得它 (web/main.py 的 EXIT → sell), 不会把清仓发成买入 + from app.core import action_engine as ae + assert ae.A_EXIT in ("TRIM", "EXIT"), ae.A_EXIT + + # 同一轮里已经有它的提议在等人 → 下一跳不重复提 (在途去重) + from app.services import command_service as csvc + restore2 = _stub_sp(csvc, {"600000.SH": {"target_price": 12.5}}) + try: + r2 = ps.scan_and_route() + finally: + restore2() + assert not any(x["action"] == "EXIT" for x in r2["queued"]), r2["queued"] + assert any("在途提议" in (s.get("why") or "") for s in r2["skipped"]), r2["skipped"] + + +@case("自主提议·目标价未到 / 没设 一律不产出 (拿不到价绝不折成到价)") +def _(): + from app.services import proposal_service as ps + fake, restore = _target_fakes(12.0, 12.5, params={"PMS_AUTONOMY": "full"}) + try: + r = ps.scan_and_route() + finally: + restore() + assert not any(x["action"] == "EXIT" for x in r["queued"] + r["executed"]), r + assert not fake.proposals, fake.proposals + + fake2, restore2 = _target_fakes(13.0, None, params={"PMS_AUTONOMY": "full"}) + try: + r2 = ps.scan_and_route() + finally: + restore2() + assert not any(x["action"] == "EXIT" for x in r2["queued"] + r2["executed"]), r2 + + # 取不到现价: 整只票跳过并留痕 —— 拿摊薄成本顶的那个价绝不能触发到价 + from app.services import command_service as csvc, market + install_fakes(prices={}, params={"PMS_TOTAL_SCALE": "2000000", "PMS_AUTONOMY": "full"}, + positions=[{"ts_code": "600000.SH", "total_qty": 6050, "avail_qty": 6050, + "base_qty": 6000, "avg_cost": 10.0, "target_pct": 0.06}]) + market.get_ma5 = lambda c: None + restore3 = _stub_sp(csvc, {"600000.SH": {"target_price": 1.0}}) # 成本 10 远超目标 1 + try: + r3 = ps.scan_and_route() + finally: + restore3() + assert not any(x["action"] == "EXIT" for x in r3["queued"] + r3["executed"]), r3 + assert any("取不到现价" in (s.get("why") or "") for s in r3["skipped"]), r3["skipped"] + + @case("自主提议·研判驳回与规则闸拦截各自留痕") def _(): from app.services import judge, proposal_service as ps