tradingSystem/app/core/rule_gate.py

246 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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
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")
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)
ml = lot_of(ctx.get("ts_code")) # 最小申报数量 (科创板 200, 其余 100)
if side == SELL:
_check_sell(failed, warns, qty, total_qty, avail_qty, day, ml)
elif side == BUY:
_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_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:
failed.append(f"OVER_SELL: 卖出 {qty} > 持仓 {total_qty}")
elif qty > avail_qty:
failed.append(f"T1_UNAVAILABLE: 卖出 {qty} > T+1 可卖 {avail_qty} (当日买入次日才可卖)")
# 清仓允许卖零股 (A股规则); 部分减持必须整百, 科创板部分减持还须 ≥200 股
if qty % LOT != 0 and qty != total_qty:
failed.append(f"LOT_INVALID: 部分减持 {qty} 股非整百 (零股只能在清仓时一次性卖出)")
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: 跌停封板, 大概率成交不了, 已按限价挂出")
def _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard,
min_lot: int = LOT):
"""增持方向: 全部硬约束都要过。"""
if qty % LOT != 0:
failed.append(f"LOT_INVALID: 买入 {qty} 股非整百")
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":
failed.append(f"FROZEN: 该股处于 {pos['frozen_reason']}, 禁止增持")
if flg.get("buy_halt"):
failed.append("BUY_HALT: 全局暂停买入生效中")
if flg.get("blacklisted"):
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:
failed.append(f"BAD_SIGNAL: 决策系统昨夜定性 {y_sig} (形态恶化), 不新增买入")
if flg.get("brake_active"):
if is_cmd:
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)
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}