PMS 两处安全修复:强制交人改为一票否决不再被卖出方向短路;目标价与止损价接通且必定交人

卖出侧:原判定把卖出方向放在或运算左边,强制交人的标记被整个短路。改为强制交人一票否决排在方向与档位之前。真正需要自动卖的两条路(风控高置信止损、用户命令清仓)不经提议分流,不受影响。
目标价:接通而非摘掉入口。到价产出清仓提议但必定交人,文案写明系统不自动卖;拿不到现价时不动。止损价参与规则检查,只告警不拦截。填 0 即取消,已写进命令说明与页面提示。
复核发现并修:同一轮可能同时产出保垫减仓与到价清仓,前者当场执行后者等人拍板,等人采纳时数量已超过实际持仓会被整条驳回。改为同轮只发一条减持,用户设的目标价优先。
测试:全套 ALL SUITES PASS,例数 626 增至 650。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-03 16:06:27 +08:00
parent 0610a7e328
commit c6034a6220
14 changed files with 1047 additions and 38 deletions

View File

@ -10,6 +10,7 @@
| ADD 盈利加仓 | 安全垫 +3% 且创 5 日新高或站上压力位 | 距上次 2 交易日; 单股上限; MA5 <+6% | | ADD 盈利加仓 | 安全垫 +3% 且创 5 日新高或站上压力位 | 距上次 2 交易日; 单股上限; MA5 <+6% |
| DCA 补仓 | 浮亏触及 8%/15% 评估档 (各评估一次, 执行终身一次) | 底仓 50%; 15% 及更深永远需用户确认 | | DCA 补仓 | 浮亏触及 8%/15% 评估档 (各评估一次, 执行终身一次) | 底仓 50%; 15% 及更深永远需用户确认 |
| TRIM 保垫减仓 | 安全垫峰值 6% 且回吐过半 1/3 锁盈 | 纯规则自动执行 (减持方向不设确认门槛) | | TRIM 保垫减仓 | 安全垫峰值 6% 且回吐过半 1/3 锁盈 | 纯规则自动执行 (减持方向不设确认门槛) |
| EXIT 目标价到价 | 现价 用户设定某股目标价命令里的价 | 全部持仓; **必定交人拍板, 绝不自动卖** |
| OPEN 新建仓 | 上游候选池里的新票, 且还有持仓名额与可投金额 | 名额与金额边走边扣; 只提底仓批 | | OPEN 新建仓 | 上游候选池里的新票, 且还有持仓名额与可投金额 | 名额与金额边走边扣; 只提底仓批 |
本模块只回答该不该动动多少为什么, 不查库不下发: 本模块只回答该不该动动多少为什么, 不查库不下发:
@ -24,7 +25,7 @@
输出候选统一结构, proposal_service 规则闸 研判闸 按自主档位分流 输出候选统一结构, 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_open() 输入是**上游候选池**, 产出 OPEN (2026-08-06 新增)
候选池取不到时 scan() 照常跑, 反之亦然 一条外部接口的故障不该让整轮扫描停摆 候选池取不到时 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_FILL, A_ADD, A_DCA, A_TRIM = "FILL", "ADD", "DCA", "TRIM"
A_OPEN = "OPEN" # 新建仓 (与 planner.A_OPEN、executor.BUY_ACTIONS 同名同义) 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" 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 起随计划接口每行下发; 旧版计划没有这个字段)。 # 上游选股系统给每张候选卡的判决 (2026-09-02 起随计划接口每行下发; 旧版计划没有这个字段)。
# 候选 门槛全过、无硬风险、确认线在 —— 走现有的自动流程 (规则闸 → 研判闸 → 档位分流) # 候选 门槛全过、无硬风险、确认线在 —— 走现有的自动流程 (规则闸 → 研判闸 → 档位分流)
# 关注 门槛全过、无硬风险、但确认线缺失或陈旧 —— 这是上游说「无法判断」, 强制交人裁决 # 关注 门槛全过、无硬风险、但确认线缺失或陈旧 —— 这是上游说「无法判断」, 强制交人裁决
@ -82,10 +126,26 @@ def _f(v, d=0.0):
return d 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), return {"ts_code": p["ts_code"], "action": action, "side": side, "qty": int(qty),
"reason": reason, "hard_numbers": hard, "needs_user_confirm": bool(confirm), "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: 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")}) "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, 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"] 的口径 positions: portfolio.positions_view()["held"] 的口径
market: {ts_code: {ma5, high5, tdays_since_open, tdays_since_last_add}} market: {ts_code: {ma5, high5, tdays_since_open, tdays_since_last_add}}
skip: 不再评估的 (代码, 动作) 字典时值是跳过原因, 集合时退回兜底话 skip: 不再评估的 (代码, 动作) 字典时值是跳过原因, 集合时退回兜底话
stock_params: 个股参数命令的当前值 {ts_code: {target_price, stop_price, black, ...}},
command_service.effective_stock_params() 给出**事实源是命令表**,
不是持仓行上的投影 投影每天盘前会被参考位取数覆盖, 拿它做判断会
在第二天早上悄悄失效不传时目标价这条动作不评估, 其余四类行为不变
返回 {"candidates": [...], "skipped": [...]} 返回 {"candidates": [...], "skipped": [...]}
""" """
skip = skip or {} skip = skip or {}
strategy_codes = strategy_codes or set() strategy_codes = strategy_codes or set()
stock_params = stock_params or {}
out, skipped = [], [] out, skipped = [], []
for p in positions or []: for p in positions or []:
code = p.get("ts_code") code = p.get("ts_code")
@ -282,6 +395,11 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
"why": "取不到现价 (price 是拿摊薄成本顶的), 本轮不评估该票"}) "why": "取不到现价 (price 是拿摊薄成本顶的), 本轮不评估该票"})
continue continue
mkt = (market or {}).get(code) or {} 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" frozen = (p.get("frozen_reason") or "NONE") != "NONE"
cands_this = [] cands_this = []
for action, fn in EVALUATORS: 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}) skipped.append({"ts_code": code, "action": action, "why": why})
continue continue
# 冻结只禁增持, 减仓照评 (与规则闸同一口径, 这里先剪枝少算一遍) # 冻结只禁增持, 减仓照评 (与规则闸同一口径, 这里先剪枝少算一遍)
if frozen and action != A_TRIM: if frozen and action not in SELL_SIDE_ACTIONS:
skipped.append({"ts_code": code, "action": action, skipped.append({"ts_code": code, "action": action,
"why": f"该股 {p['frozen_reason']}, 禁增持"}) "why": f"该股 {p['frozen_reason']}, 禁增持"})
continue continue
@ -304,14 +422,33 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
cands_this.append(c) cands_this.append(c)
# 同一只票同轮买卖互斥 (2026-08-28 审查修): 保垫减仓的峰值是**全时段**只增不减, # 同一只票同轮买卖互斥 (2026-08-28 审查修): 保垫减仓的峰值是**全时段**只增不减,
# 加仓判据看的是近 5 日窗口, 两套时间基准可以同时成立 —— 一轮里对同一只票 # 加仓判据看的是近 5 日窗口, 两套时间基准可以同时成立 —— 一轮里对同一只票
# 一边 TRIM 锁盈一边 ADD 加仓, 自动对倒空耗手续费。触发 TRIM 时买入侧让路 # 一边 TRIM 锁盈一边 ADD 加仓, 自动对倒空耗手续费。触发减持时买入侧让路
# (先落袋为安, 保守方向优先), 买入条件真成立的话下一轮 TRIM 不触发时自然会来。 # (先落袋为安, 保守方向优先), 买入条件真成立的话下一轮不触发减持时自然会来。
if any(c["action"] == A_TRIM for c in cands_this): # 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: for c in cands_this:
if c["side"] == BUY: if c["side"] == BUY:
skipped.append({"ts_code": code, "action": c["action"], 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] 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) out.extend(cands_this)
return {"candidates": out, "skipped": skipped} return {"candidates": out, "skipped": skipped}

View File

@ -212,12 +212,22 @@ SPECS = {
"cls": CLS_PARAM, "label": "设定某股止损价", "group": "C", "scope": "stock", "cls": CLS_PARAM, "label": "设定某股止损价", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)}, "fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)},
"projection": {"stop_ref": "@price", "ref_source": "user"}, "projection": {"stop_ref": "@price", "ref_source": "user"},
"note": "覆盖系统参考位; 触发即生成卖出方案", # 2026-09-03 改口径 (原文写的是「触发即生成卖出方案」, 而实现里从来没有这一步):
# 止损价现在参与规则检查, 但**只披露不拦截、也不自动卖**。跌破时每条买入指令的
# 评审账本上会带一句 STOP_BREACHED, 页面参考位列也显示它。要真卖请下清仓/减持命令。
"note": "覆盖系统参考位; 跌破只在合规检查里告警留痕, 不自动卖出",
}, },
"SET_TARGET_PRICE": { "SET_TARGET_PRICE": {
"cls": CLS_PARAM, "label": "设定某股目标价", "group": "C", "scope": "stock", "cls": CLS_PARAM, "label": "设定某股目标价", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)}, "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": { "BLACKLIST_ADD": {
"cls": CLS_PARAM, "label": "加入黑名单(永不买入)", "group": "C", "scope": "stock", "cls": CLS_PARAM, "label": "加入黑名单(永不买入)", "group": "C", "scope": "stock",

View File

@ -52,8 +52,12 @@ def check(*, side: str, action: str, qty: int, price: float, ctx: dict) -> dict:
caps check_all_caps 所需组合上下文 (仅买入用) caps check_all_caps 所需组合上下文 (仅买入用)
day {price, vwap, ma5, day_chg_from_open, halted, limit_up, limit_down} day {price, vwap, ma5, day_chg_from_open, halted, limit_up, limit_down}
params {no_chase_ma5, buy_halt_dayup, sector_source_ready} params {no_chase_ma5, buy_halt_dayup, sector_source_ready}
flags {buy_halt, exec_halt, brake_active, blacklisted, is_command, y_signal} flags {buy_halt, exec_halt, brake_active, blacklisted, is_command, y_signal,
stop_price}
y_signal 是决策系统昨夜结论对该股的定性, 由择时应答带回 (可能为 None) y_signal 是决策系统昨夜结论对该股的定性, 由择时应答带回 (可能为 None)
stop_price 是用户设定某股止损价命令里的价 (可能为 None), 事实源是
命令表 持仓行上的 stop_ref 每天盘前会被参考位取数覆盖,
拿投影做判断会在第二天早上悄悄失效
""" """
pos = ctx.get("position") or {} pos = ctx.get("position") or {}
day = ctx.get("day") 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"): if flg.get("exec_halt"):
failed.append("EXEC_HALT: 全局暂停执行 (休假模式) 生效中") failed.append("EXEC_HALT: 全局暂停执行 (休假模式) 生效中")
_check_user_stop(warns, side, price, flg, hard)
ml = lot_of(ctx.get("ts_code")) # 最小申报数量 (科创板 200, 其余 100) ml = lot_of(ctx.get("ts_code")) # 最小申报数量 (科创板 200, 其余 100)
if side == SELL: if side == SELL:
_check_sell(failed, warns, qty, total_qty, avail_qty, day, ml) _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} return {"passed": not failed, "failed": failed, "warnings": warns, "hard_numbers": hard}
def _check_user_stop(warns, side, price, flg, hard):
"""用户设的止损价: **只披露不拦截** (2026-09-03 接通「设定某股止损价」这条命令)。
在此之前这条命令的价格只存进命令表, 全库没有一处读它 页面能设状态是生效中,
而跌破那天什么都不会发生这里把它接进规则闸的告警层, 与既有的敞口披露
LIMIT_DOWNCASH_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): def _check_sell(failed, warns, qty, total_qty, avail_qty, day, min_lot: int = LOT):
"""减持方向: 冻结/刹车/上限一概不拦, 只看「卖得出去吗」。""" """减持方向: 冻结/刹车/上限一概不拦, 只看「卖得出去吗」。"""
if qty > total_qty: if qty > total_qty:

View File

@ -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"], "flags": {"buy_halt": prm["buy_halt"], "exec_halt": prm["exec_halt"],
"brake_active": brake_active, "brake_active": brake_active,
"blacklisted": bool(stock_params.get(code, {}).get("black")), "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")), "is_command": bool(prog.get("is_command")),
# 决策系统昨夜对该股的定性, 由择时应答带回 (退实现B 时为 None) # 决策系统昨夜对该股的定性, 由择时应答带回 (退实现B 时为 None)
"y_signal": d.get("y_signal")}}) "y_signal": d.get("y_signal")}})

View File

@ -90,6 +90,46 @@ FAIL_CLOSED = {
"PMS_AUTO_STRATEGY_ENABLED": False, "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 取不到, 故在此集中维护) # 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护)
DESC = { DESC = {
"PMS_TOTAL_SCALE": "总操作规模 (元) —— 所有百分比约束的分母", "PMS_TOTAL_SCALE": "总操作规模 (元) —— 所有百分比约束的分母",

View File

@ -8,7 +8,10 @@
off 不扫描 off 不扫描
两条无条件覆盖档位的规矩: 两条无条件覆盖档位的规矩:
* **减持方向不设确认门槛** TRIM 保垫减仓属纯规则自动执行, 任何档位都直接落指令 * **规则算出来的减持不设确认门槛** TRIM 保垫减仓属纯规则自动执行, 任何档位都直接落
指令但这一条覆盖的是**档位**, 不覆盖强制入人工队列这个标记 (2026-09-03 ): 带了
这个标记的减持一律交人, 方向是卖也不例外哪种减持带标记由候选的来源说了算,
`action_engine.source_confirm_why` `_route_one` 里那段说明
* **15% 及更深的补仓永远需用户确认** 即便档位是 full, 也强制入队 * **15% 及更深的补仓永远需用户确认** 即便档位是 full, 也强制入队
研判闸不可用时 (决策系统未接通/超时), 按设计自动降级为 propose_only + ERROR 告警, 研判闸不可用时 (决策系统未接通/超时), 按设计自动降级为 propose_only + ERROR 告警,
@ -77,6 +80,9 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
try: try:
view = portfolio.positions_view() view = portfolio.positions_view()
params = _scan_params(view) params = _scan_params(view)
# 个股参数命令的当前值 (黑名单、目标价、止损价…)。取数提到扫描之前, 因为动作引擎
# 现在要用它评「目标价到价」那条动作; 后面分流与规则闸用的是同一份, 不重复查。
stock_params = command_service.effective_stock_params()
mkt = _market_ctx(view["held"], now) mkt = _market_ctx(view["held"], now)
params["_mkt"] = mkt # 规则闸要用同一份 MA5, 不再重取 params["_mkt"] = mkt # 规则闸要用同一份 MA5, 不再重取
# 跳过三类: ①已有在途提议或指令的 ②今天已被规则闸拒过的 ③今天已被研判闸驳回的新建仓。 # 跳过三类: ①已有在途提议或指令的 ②今天已被规则闸拒过的 ③今天已被研判闸驳回的新建仓。
@ -98,7 +104,7 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
except Exception: except Exception:
strategy_codes = set() strategy_codes = set()
scanned = ae.scan(positions=view["held"], params=params, market=mkt, skip=skip, 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: except Exception as e:
logger.exception("提议扫描失败") logger.exception("提议扫描失败")
return {**out, "ok": False, "errors": [f"扫描失败: {type(e).__name__}: {e}"]} 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["candidates"] = len(scanned["candidates"])
out["skipped"].extend(scanned["skipped"]) out["skipped"].extend(scanned["skipped"])
stock_params = command_service.effective_stock_params()
brake_active = td.ymd() < param_store.get_int("PMS_BRAKE_UNTIL", 0) 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"), "flags": {"buy_halt": params.get("buy_halt"), "exec_halt": params.get("exec_halt"),
"brake_active": brake_active, "brake_active": brake_active,
"blacklisted": bool(stock_params.get(code, {}).get("black")), "blacklisted": bool(stock_params.get(code, {}).get("black")),
# 用户设的止损价, 与黑名单同一份事实源 (命令表)。规则闸拿它只告警
# 不拦截, 见 rule_gate 里那段说明。
"stop_price": stock_params.get(code, {}).get("stop_price"),
"is_command": False}}) "is_command": False}})
if not gate["passed"]: if not gate["passed"]:
out["rejected"].append({"ts_code": code, "action": action, "by": "rule", 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), 不跟随全局 —— 「新建仓要不要人点头」和 # 新建仓走自己的档位 (PMS_OPEN_AUTONOMY), 不跟随全局 —— 「新建仓要不要人点头」和
# 「加仓要不要人点头」是两个不同的决定。全局 off 已经在 scan_and_route 入口拦掉了, # 「加仓要不要人点头」是两个不同的决定。全局 off 已经在 scan_and_route 入口拦掉了,
# 走到这里说明总闸是开的。三条无条件覆盖档位的规矩对新建仓一样有效: 减持自动、 # 走到这里说明总闸是开的。三条无条件覆盖档位的规矩对新建仓一样有效: 规则算出来的减持
# 深档补仓强制确认、研判不可用一律入队。 # 自动执行、深档补仓强制确认、研判不可用一律入队。
autonomy = (out.get("open_autonomy") or AUTONOMY_FULL) if is_open else out["autonomy"] 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") # 强制入人工队列是**一票否决**, 排在方向与档位前面 (2026-09-03 修)。原来这一行是
auto_exec = (side == "sell") or (autonomy == AUTONOMY_FULL and not force_queue) # 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 时, # 自动执行开关 (2026-09-03, PMS_OPEN_AUTO_EXEC_ON_VERDICT): 新建仓档位是 propose_only 时,
# 「判决候选 + 决策系统研判真回了通过 + 规则闸通过 (走到这里就是通过了) + 上游风险列表 # 「判决候选 + 决策系统研判真回了通过 + 规则闸通过 (走到这里就是通过了) + 上游风险列表
# 为空」四条齐, 这一条按 full 处理。强制入队的 (关注 / 深档补仓 / 研判不可用) 永远不走。 # 为空」四条齐, 这一条按 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"], verdict="PASS", price_at=price, hard_numbers=c["hard_numbers"],
ref_id=iid, reason=reason[:500]) ref_id=iid, reason=reason[:500])
out["executed"].append({**_brief(c), "instruction_id": iid, 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 (auto_why or ("新建仓档位 full" if is_open
else "档位 full")))}) else "档位 full")))})
else: else:
pid = _make_proposal(c, price, verdict) pid = _make_proposal(c, price, verdict)
# 强制确认的原因: 候选自带的 (关注判决等, 见 action_engine.verdict_confirm_why) # 交人的原因按从具体到笼统取: 候选自带的 (关注判决等, 见 action_engine.verdict_confirm_why)
# 优先, 没有就是深档补仓那条老规矩。 # → 来源强制的 (研究证据走弱那类减持) → 深档补仓那条老规矩 → 研判不可用 → 档位。
why = ((c.get("confirm_why") or "深档补仓强制确认") if c.get("needs_user_confirm") why = (c.get("confirm_why") or src_why
else ("研判不可用, 降级人工确认" if verdict.get("degraded") or ("深档补仓强制确认" if c.get("needs_user_confirm") else None)
else ("新建仓档位 propose_only" if is_open else "档位 propose_only"))) 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}) 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']}" pid = f"PRP_{td.ymd()}_{c['ts_code'].replace('.', '')}_{c['action']}"
hn = {**(c.get("hard_numbers") or {}), "price": price, "reason": c["reason"], hn = {**(c.get("hard_numbers") or {}), "price": price, "reason": c["reason"],
"needs_user_confirm": c.get("needs_user_confirm", False), "needs_user_confirm": c.get("needs_user_confirm", False),
# 候选来源 (2026-09-03): 人在「等我拍板」里要看得出这条减持是规则算的还是研究
# 证据走弱推来的 —— 两者该不该点头是两回事。没有来源的按动作引擎自身处理。
"source": c.get("source") or ae.SRC_ENGINE,
# 研判应答的结论与置信度 (2026-09-03): 人裁决时要看得见决策系统怎么说、有多确定。 # 研判应答的结论与置信度 (2026-09-03): 人裁决时要看得见决策系统怎么说、有多确定。
# judge_reason 另有一列, 这两项进硬数字是为了随账本走 (采纳/驳回时原样落账)。 # judge_reason 另有一列, 这两项进硬数字是为了随账本走 (采纳/驳回时原样落账)。
"judge_verdict": verdict.get("verdict"), "judge_conf": verdict.get("confidence")} "judge_verdict": verdict.get("verdict"), "judge_conf": verdict.get("confidence")}

View File

@ -2392,12 +2392,14 @@ createApp({
} }
async function actStop(row) { async function actStop(row) {
const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—') 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 }); if (v != null) quickCmd('SET_STOP_PRICE', { ts_code: row.ts_code, price: v });
} }
async function actTargetPrice(row) { async function actTargetPrice(row) {
const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—') 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 }); if (v != null) quickCmd('SET_TARGET_PRICE', { ts_code: row.ts_code, price: v });
} }
function actFreeze(row) { function actFreeze(row) {

View File

@ -7,15 +7,18 @@
包含 (例数按 2026-09-03 实跑校正; 此前写的 591 是过期数字, 当时实跑已是 608): 包含 (例数按 2026-09-03 实跑校正; 此前写的 591 是过期数字, 当时实跑已是 608):
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 ) test_core_units.py 仓位规划器 / 安全垫与成本账 (14 )
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 ) test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 )
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (25 ) test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 / 用户止损价只披露不拦截 (26 )
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 ) test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 +
目标价到价产出清仓候选 (必定交人裁决) +
同轮只发一条减持 (到价清仓优先于保垫减仓) (15 )
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 上游选股计划: 解析/新鲜度/候选筛选/取数守卫/
候选卡五键透传与截断/仅展示在候选阶段剔除 (35 ) 候选卡五键透传与截断/仅展示在候选阶段剔除 (35 )
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 ) test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 )
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 ) test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 )
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (45 ) test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 +
页面能改的参数必须有人读 ([P] 组静态扫描) (47 )
test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 ) test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 )
test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/ test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/
转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/ 转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/
@ -44,8 +47,12 @@
开关四条件与任一缺失仍入队/信号来源区分/密钥名单 开关四条件与任一缺失仍入队/信号来源区分/密钥名单
含会话密钥/研判键放行与必答题/置信度进硬数字/ 含会话密钥/研判键放行与必答题/置信度进硬数字/
页面静态守卫 (15 ) 页面静态守卫 (15 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (67 ) test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/
626 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) (69 )
649
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
@ -64,6 +71,12 @@
密钥名单 scripts/test_batch6_units.py 用例密钥不进 ParamStore (协议 §10.1.1)(约第 密钥名单 scripts/test_batch6_units.py 用例密钥不进 ParamStore (协议 §10.1.1)(约第
642 ) scripts/test_batch20_units.py 用例密钥名单·会话密钥 642 ) scripts/test_batch20_units.py 用例密钥名单·会话密钥
新增密钥类配置要同时进 param_store.SECRET_KEYS 与这两处断言 新增密钥类配置要同时进 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 os
import subprocess 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_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py",
"test_batch16_units.py", "test_batch17_units.py", "test_batch16_units.py", "test_batch17_units.py",
"test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py", "test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py",
"test_batch21_units.py",
"test_wiring.py"] "test_wiring.py"]

View File

@ -960,6 +960,125 @@ def _():
qmt_repo.get_order = orig 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(): def main():
ok = fail = 0 ok = fail = 0

View File

@ -362,7 +362,11 @@ def _():
def _(): def _():
assert ae.JUDGE_ACTIONS == {"FILL", "ADD", "DCA", "OPEN"}, ae.JUDGE_ACTIONS assert ae.JUDGE_ACTIONS == {"FILL", "ADD", "DCA", "OPEN"}, ae.JUDGE_ACTIONS
assert "TRIM" not in 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={}) r = ae.scan(positions=[], params=params(), market={})
assert r == {"candidates": [], "skipped": []}, r assert r == {"candidates": [], "skipped": []}, r

View File

@ -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()

View File

@ -254,6 +254,35 @@ def ctx(**kw):
return d 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("规则闸·卖出正常放行 + 硬数字留痕") @case("规则闸·卖出正常放行 + 硬数字留痕")
def _(): def _():
r = rg.check(side="sell", action="TRIM", qty=2000, price=10.0, ctx=ctx()) r = rg.check(side="sell", action="TRIM", qty=2000, price=10.0, ctx=ctx())

View File

@ -141,6 +141,81 @@ def _():
PARAMS) is None # 1/3 不足一手 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("扫描·冻结票只评减仓 / 已在途不重复提 / 单票异常不拖垮整轮") @case("扫描·冻结票只评减仓 / 已在途不重复提 / 单票异常不拖垮整轮")
def _(): def _():

View File

@ -1677,6 +1677,88 @@ def _():
judge.request = orig 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("自主提议·研判驳回与规则闸拦截各自留痕") @case("自主提议·研判驳回与规则闸拦截各自留痕")
def _(): def _():
from app.services import judge, proposal_service as ps from app.services import judge, proposal_service as ps