tradingSystem/app/core/rule_gate.py

205 lines
10 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
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}
y_signal 是决策系统昨夜结论对该股的定性, 由择时应答带回 (可能为 None)
"""
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: 全局暂停执行 (休假模式) 生效中")
if side == SELL:
_check_sell(failed, warns, qty, total_qty, avail_qty, day)
elif side == BUY:
_check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard)
else:
failed.append(f"SIDE_INVALID: 方向 {side!r} 非法")
return {"passed": not failed, "failed": failed, "warnings": warns, "hard_numbers": hard}
def _check_sell(failed, warns, qty, total_qty, avail_qty, day):
"""减持方向: 冻结/刹车/上限一概不拦, 只看「卖得出去吗」。"""
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股规则); 部分减持必须整百
if qty % LOT != 0 and qty != total_qty:
failed.append(f"LOT_INVALID: 部分减持 {qty} 股非整百 (零股只能在清仓时一次性卖出)")
if day.get("limit_down"):
warns.append("LIMIT_DOWN: 跌停封板, 大概率成交不了, 已按限价挂出")
def _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard):
"""增持方向: 全部硬约束都要过。"""
if qty % LOT != 0:
failed.append(f"LOT_INVALID: 买入 {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}