tradingSystem/app/core/rule_gate.py

268 lines
14 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
规则闸 · 一级关口 (纯逻辑, 无外部依赖, 可单测)
================================================
设计 POSITION_MGMT_DESIGN.md §7:
全指令必过, PMS 纯代码下发前用最新数据终检 命令参数上限一手检查
T+1 可卖冻结状态刹车状态行业集中度(买入)不追高
任一不过或必要输入缺失 拒绝 (宁可不动), 未通过项落账本
两条容易搞反的口径, 在此钉死:
1. **冻结与刹车只挡增持, 不挡减持** 卖出止损任何时候都放行 (设计 §3.1/§5)
2. **命令驱动不受刹车限制** 刹车是给自主提议踩的, 用户命令优先, 只提示不拦
(设计 §5命令类不受限, 执行前提示)上限/一手/可卖量这类硬约束则一视同仁
输出结构固定为 {"passed", "failed", "warnings", "hard_numbers"}, 直接喂 pms_action_ledger
failed_checks_json / hard_numbers_json 拒了的后来涨了多少靠这份留痕做判分
"""
from __future__ import annotations
2026-08-28 13:07:27 +08:00
from app.core.sizer import LOT, lot_of
from app.core.planner import check_all_caps
BUY, SELL = "buy", "sell"
# 决策系统昨夜结论的定性里算"形态恶化"的三个取值 (2026-08-05 补的第二道防线)。
#
# 这三个值一共有三处在用, 改一处必须三处一起改:
# 桥 akg-factor-bridge/pool.py 的 BAD_SIGNALS —— 拿它把票移出候选池、记进回收站;
# 决策系统 app/services/pms_advisor.py 的 BAD_Y_SIGNALS —— 拿它在择时应答里拦住买入;
# 这里 —— 拿它在指令下发前终检时再拦一次, 并且把拒绝写进 pms_action_ledger。
#
# 为什么明明决策系统那侧已经拦了、这里还要再拦一道: 那一侧拦掉的东西只写在它自己的应答里,
# PMS 的评审账本 (make t-gate) 一个字看不到; 而评审账本是"拒了的后来涨了多少"那份判分的
# 事实源, 拒绝不落在这张表里就等于没发生过。要注意这道闸的数据只来自实现A的应答
# (含它十个交易分钟的缓存): 退实现B时 PMS 手里根本没有决策系统的定性, 这道闸自然不生效 ——
# 那是"拿不到意见"的正常降级, 不是漏拦。
BAD_Y_SIGNALS = ("SELL", "AVOID", "DROPPED")
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
# 冻结的三种来路对交易员是三件不同的事,原先一句话三种都不分, 还把枚举值印出来了。
_FROZEN_CN = {"COMMAND_HALT": "你下过命令把这只票设成只卖不买",
"MANUAL": "你手动把这只票设成只卖不买",
"BRAKE": "组合刹车期间这只票只卖不买"}
# 昨夜信号的枚举值。它们是决策系统与本系统之间传递用的, 不该出现在给人看的句子里。
# 「已剔除」单独成句 —— 「给出已剔除信号」不成话。
# 这份表与选股系统 akg-factor-bridge/card.py 的 SIGNAL_CN 同源: 两个系统各自独立部署,
# 进程之间 import 不到, 只能各存一份同名同值的, 改一处必须两处同改。
_SIGNAL_CN = {"SELL": "择时决策系统昨天收盘后给这只票打了卖出信号",
"AVOID": "择时决策系统昨天收盘后给这只票打了回避信号",
"DROPPED": "择时决策系统昨天收盘后把这只票从名单里去掉了"}
def _frozen_cn(reason) -> str:
return _FROZEN_CN.get(str(reason or "").strip(), "这只票现在只卖不买")
def _signal_cn(sig) -> str:
s = str(sig or "").strip().upper()
return _SIGNAL_CN.get(s, f"择时决策系统昨天收盘后给这只票打了不看好的信号({s}")
def _num(v, default=0.0):
try:
return float(v)
except (TypeError, ValueError):
return default
def check(*, side: str, action: str, qty: int, price: float, ctx: dict) -> dict:
"""指令下发前终检。
ctx 由调用方备齐 (services/executor.py):
position {total_qty, avail_qty, frozen_reason} 该股账本快照
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,
stop_price}
y_signal 是决策系统昨夜结论对该股的定性, 由择时应答带回 (可能为 None)
stop_price 是用户设定某股止损价命令里的价 (可能为 None), 事实源是
命令表 持仓行上的 stop_ref 每天盘前会被参考位取数覆盖,
拿投影做判断会在第二天早上悄悄失效
"""
pos = ctx.get("position") or {}
day = ctx.get("day") or {}
prm = ctx.get("params") or {}
flg = ctx.get("flags") or {}
is_cmd = bool(flg.get("is_command"))
side = str(side or "").lower()
failed, warns = [], []
qty = int(qty or 0)
price = _num(price)
total_qty = int(pos.get("total_qty") or 0)
avail_qty = int(pos.get("avail_qty") or 0)
hard = {"side": side, "action": action, "qty": qty, "price": price,
"total_qty": total_qty, "avail_qty": avail_qty,
"frozen_reason": pos.get("frozen_reason") or "NONE",
"day_chg_from_open": day.get("day_chg_from_open"),
"vwap": day.get("vwap"), "ma5": day.get("ma5"), "is_command": is_cmd}
# ---- 通用: 必要输入缺失一律拒绝 (宁可不动) ----
if qty <= 0:
failed.append(f"QTY_INVALID: 数量 {qty} 非法")
if price <= 0:
failed.append("PRICE_MISSING: 取不到现价, 无法定限价与校验 (宁可不动)")
if day.get("halted"):
failed.append("HALTED: 停牌, 当日跳过顺延")
if flg.get("exec_halt"):
failed.append("EXEC_HALT: 全局暂停执行 (休假模式) 生效中")
_check_user_stop(warns, side, price, flg, hard)
2026-08-28 13:07:27 +08:00
ml = lot_of(ctx.get("ts_code")) # 最小申报数量 (科创板 200, 其余 100)
if side == SELL:
2026-08-28 13:07:27 +08:00
_check_sell(failed, warns, qty, total_qty, avail_qty, day, ml)
elif side == BUY:
2026-08-28 13:07:27 +08:00
_check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard, ml)
else:
failed.append(f"SIDE_INVALID: 方向 {side!r} 非法")
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"—— 只提示不拦截; 止损价也不会自动挂单卖出")
2026-08-28 13:07:27 +08:00
def _check_sell(failed, warns, qty, total_qty, avail_qty, day, min_lot: int = LOT):
"""减持方向: 冻结/刹车/上限一概不拦, 只看「卖得出去吗」。"""
if qty > total_qty:
failed.append(f"OVER_SELL: 卖出 {qty} > 持仓 {total_qty}")
elif qty > avail_qty:
failed.append(f"T1_UNAVAILABLE: 卖出 {qty} > T+1 可卖 {avail_qty} (当日买入次日才可卖)")
2026-08-28 13:07:27 +08:00
# 清仓允许卖零股 (A股规则); 部分减持必须整百, 科创板部分减持还须 ≥200 股
if qty % LOT != 0 and qty != total_qty:
failed.append(f"LOT_INVALID: 部分减持 {qty} 股非整百 (零股只能在清仓时一次性卖出)")
2026-08-28 13:07:27 +08:00
elif min_lot > LOT and 0 < qty < min_lot and qty != total_qty:
failed.append(f"LOT_INVALID: 科创板部分减持最少 {min_lot} 股 (卖 {qty} 股不合法; "
f"余额不足 {min_lot} 股只能一次性清出)")
if day.get("limit_down"):
warns.append("LIMIT_DOWN: 跌停封板, 大概率成交不了, 已按限价挂出")
2026-08-28 13:07:27 +08:00
def _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard,
min_lot: int = LOT):
"""增持方向: 全部硬约束都要过。"""
if qty % LOT != 0:
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
failed.append(f"LOT_INVALID: 买 {qty} 股不是一百的整数倍,交易所不收")
2026-08-28 13:07:27 +08:00
elif min_lot > LOT and 0 < qty < min_lot:
failed.append(f"LOT_INVALID: 科创板买入申报最少 {min_lot} 股 (买 {qty} 股会被交易所拒单)")
if (pos.get("frozen_reason") or "NONE") != "NONE":
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
failed.append(f"FROZEN: {_frozen_cn(pos.get('frozen_reason'))},这笔买 {qty} 股不下单")
if flg.get("buy_halt"):
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
failed.append("BUY_HALT: 现在全局暂停买入,所有票都不新增")
if flg.get("blacklisted"):
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
failed.append("BLACKLIST: 这只票在黑名单里,任何情况都不买")
# 决策系统昨夜定性为形态恶化 —— 无论有没有拿到定性, 都写进 hard_numbers, 这样
# 评审账本上"放行的那些当时定性是什么"同样查得到, 而不是只有被拒的才留下痕迹。
y_sig = str(flg.get("y_signal") or "").strip().upper()
hard["y_signal"] = y_sig or None
if y_sig in BAD_Y_SIGNALS:
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
failed.append(f"BAD_SIGNAL: {_signal_cn(y_sig)},今天不买")
if flg.get("brake_active"):
if is_cmd:
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
warns.append("BRAKE_ACTIVE: 组合正在刹车期,但这是你下的命令,不受限制,这里只作提示")
else:
failed.append("BRAKE_ACTIVE: 组合刹车中, 自主增持暂停")
if day.get("limit_up"):
failed.append("LIMIT_UP: 涨停封板, 不追买")
# 不追高: 当日涨幅 与 距 MA5 两道
dayup = day.get("day_chg_from_open")
cap_dayup = _num(prm.get("buy_halt_dayup"), 0.05)
2026-07-31 16:10:58 +08:00
if dayup is None:
# 缺输入**必须留痕**, 与下面 MA5_MISSING 同口径。原来只有 `is not None` 一个条件,
# 缺了就整道闸无声跳过 —— 而自主提议那条路一直硬编码 None, 于是动作引擎产出的每一笔
# 买单都没过过这道闸, 返回值还是 `passed=True, failed=[], warnings=[]`, 与
# 「涨幅 2%、检查通过」一字不差, 账本里也查不出来。
warns.append("DAYUP_MISSING: 取不到当日涨幅, 不追高(涨幅)一项未校验")
elif _num(dayup) > cap_dayup:
failed.append(f"NO_CHASE_DAYUP: 当日涨幅 {_num(dayup):.2%} > 上限 {cap_dayup:.0%}")
ma5 = _num(day.get("ma5"))
cap_ma5 = _num(prm.get("no_chase_ma5"), 0.06)
if ma5 > 0:
gap = price / ma5 - 1
hard["ma5_gap"] = round(gap, 4)
if gap > cap_ma5:
failed.append(f"NO_CHASE_MA5: 距 MA5 {gap:.2%} > 上限 {cap_ma5:.0%}")
else:
warns.append("MA5_MISSING: 取不到 MA5, 距均线一项未校验")
caps = ctx.get("caps")
if caps:
bad = check_all_caps(ts_code=ctx.get("ts_code") or "-", add_amount=qty * price, ctx=caps)
failed.extend(bad)
if not caps.get("sector_source_ready", True):
warns.append("SECTOR_OFF: 行业数据源未配置, 行业集中度未校验 (页面已提示)")
_check_cash(failed, warns, qty, price, caps, hard)
else:
failed.append("CAPS_MISSING: 缺组合上下文, 无法校验上限 (宁可不动)")
def _check_cash(failed, warns, qty, price, caps, hard):
"""买入前的资金校验 (QMT_INTERFACE_REQUIREMENTS A3 的原意)。
与组合上限是**两个不同的约束**, 所以单列一条而不是塞进 check_all_caps:
上限管该不该买这么多 仓位纪律, 基准是 PMS_TOTAL_SCALE 这个用户命令参数
资金管买不买得起 账户事实, 基准是 ws 资金快照的 available_cash + 当日回笼
2026-07-30 之前只有前者: scale=200 万而模拟账户实际只有 98 , 于是排出的方案规则闸一路
放行, 要等 QMT `INSUFFICIENT_CASH` 才被拒; reject 不自动重发, 择时下一跳又算又发
又拒 页面看着一切正常, 实际一单也下不去
**拿不到真实资金时降级不拦, 但必须留痕** 与研判闸拿不到不算通过也不当拒绝, 降级
并记录同一口径 一律拒会让资金快照一断就把所有买入停掉, 那是把通道故障升级成业务
停摆降级期间 QMT 那层的 `INSUFFICIENT_CASH` 仍是最后一道防线, 只是会吵一点
"""
src = str(caps.get("cash_source") or "estimate")
avail = caps.get("cash_avail")
need = round(qty * price, 2)
hard.update({"cash_need": need, "cash_source": src,
"cash_avail": None if avail is None else round(_num(avail), 2)})
if src != "ws" or avail is None:
warns.append("CASH_ESTIMATED: 拿不到 ws 资金快照, 本单未按真实可用资金校验 "
"(scale市值 的估算值不能当真钱用)")
return
if need > _num(avail):
failed.append(f"INSUFFICIENT_CASH: 需 {need:.0f} 元 > 可用 {_num(avail):.0f}"
f"(含当日卖出回笼)")
def summarize(results: list) -> dict:
"""一批指令的闸门统计 (日报关注区用)。"""
passed = [r for r in results if r.get("passed")]
reasons = {}
for r in results:
for f in r.get("failed") or []:
k = f.split(":")[0]
reasons[k] = reasons.get(k, 0) + 1
return {"total": len(results), "passed": len(passed),
"rejected": len(results) - len(passed), "by_reason": reasons}