# -*- coding: utf-8 -*- """研判驳回后的「解锁重问」判定 (2026-09-09 方案第四之一节, 台账 055)。 纯逻辑, 零外部依赖, 可离线单测 —— 与 signal_rules、exec_timing 同一手法。 要解决什么: 新建仓被研判闸驳回后, 那只票当天就进了跳过集合, 扫描每分钟仍在跑但直接 略过它。这道闸挡的是真问题 (2026-07-29 那次每分钟写一行驳回、一天几千行, 把判分锚 淹掉), 假设也合理 ——「这只票今天不该从零建仓这个结论当天基本不会翻转」—— 但它不是 绝对的, 缺一个「确实有新信息」的例外。这里就是那个例外。 判定式: 解锁 = G0 且 G1 且 G2 且 G3 且 (U1 或 U2 或 U3) 四条护栏 (全要): G0 总开关开着 G1 距驳回已过 20 分钟 —— 不是等择时侧那层 30 分钟缓存过期 (重问带强制标记绕开它), 是等量能确认成形: 20 分钟等于四根五分钟线 G2 这只票今天还没重问过 —— 每票每天一次。两次以上就等于取消当日闸 G3 当前在 09:45 到 14:30 之间 —— 早于 09:45 时段折算量比不可靠; 晚于 14:30 即便放行, 提议到人拍板到落指令到择时区间, 来不及 三条解锁条件 (任一成立): U1 择时决策系统当日判过这只票转多, 且**时刻晚于驳回** —— 另一套系统自己改了口, 是最硬的一种新信息。"晚于驳回"这个限定是关键: 驳回之前 就有的信号研判当时已经看过了 (它作为盘中转多留痕进了提示词), 拿它当理由是循环论证 U2 昨夜结论被盘中重算改写, 且方向向好 (定性翻成看多, 或支撑压力漂了 3%) —— 研判的底牌就是那一行。它被改写意味着研判当时依据的东西已经不在了。 3% 直接取 PMS_OPEN_REF_DRIFT_MAX 同值: 那道闸说「底牌漂了就别买」, 这条说 「底牌漂了就重问一次」, 同一件事的两个方向用同一个数, 复盘能直接对照 U3 价升且量能确认: 现价较驳回涨 2%, 且时段折算量比 ≥ 1.5, 且量比涨到驳回时的 1.3 倍 —— 直冲「今日突破量能未确认」那类驳回理由。三条**并列**才算: 最常见的假信号正是 「价格再涨一点但量没跟上」, 只看价格等于在追高; 只看量比会被早盘脉冲骗 **取数失败一律判该条不成立** (fail-closed): 方向是漏解锁, 不是误解锁。与规则闸 「缺输入必须留痕」、研判闸「拿不到不等于通过」是同一条纪律。 明确不做的两条 (记下来免得日后当成遗漏): · 「现价回落进择时买入区间」—— 区间公式在择时侧, PMS 复制一份就是第二个事实源; 而且 09-09 拍板是「不改追高原则」, 这条属于另一件事 · 纯时间兜底 (比如过一小时无条件重问) —— 那是取消当日闸, 不是解除 """ from __future__ import annotations from datetime import datetime from typing import Optional ELAPSED_FULL_MIN = 240 # 全日交易分钟: 09:30-11:30 与 13:00-15:00 各 120 VOL_SANITY_MAX = 30.0 # 折算前的原始比超过它判数据异常 (口径与择时侧同源) # 覆盖时段占全日不足这个比例就不判量比。0.08 约合 20 分钟 —— 实测 db13 的分钟线是滚动 # 窗口, 不保证从开盘存起 (09-09 四方股份首根是 10:11), 覆盖太短时量比抖得没有意义。 FRAC_MIN = 0.08 def _f(v, d=None): try: x = float(v) except (TypeError, ValueError): return d return x def _minutes(t: datetime) -> int: return t.hour * 60 + t.minute def elapsed_trading_frac(now: datetime) -> float: """当日已进行的交易时间占比。盘前 0, 收盘后 1。 口径与择时侧的两处逐字对齐 (台账 055 记了「三处同源」这条纪律)。 """ m = _minutes(now) if m < 9 * 60 + 30: return 0.0 if m <= 11 * 60 + 30: return (m - (9 * 60 + 30)) / float(ELAPSED_FULL_MIN) if m < 13 * 60: return 120 / float(ELAPSED_FULL_MIN) if m <= 15 * 60: return (120 + (m - 13 * 60)) / float(ELAPSED_FULL_MIN) return 1.0 def vol_ratio(cum_vol_shares, vol_base_lots, frac) -> Optional[float]: """时段折算量比 = (当日累计量 ÷ 100) ÷ 近五日日均量 ÷ 已过时段占比。 两个单位不一样: 分钟线的量是**股**, 因子表的均量是**手**, 所以要除以 100。 这个除法是最容易在日后被人"顺手删掉"的一行, 两侧单测各钉了同一组边界数。 三条哨兵, 任一触发回 None (由调用方当作「读不到」, 于是该条解锁不成立): · 时段占比太小 —— 开盘头几分钟的量比没有意义 · 折算前的原始比超过 30 —— 多半是上游改了单位, 宁可不判也不误判 · 任一入参取不到或不是正数 """ v, base, fr = _f(cum_vol_shares), _f(vol_base_lots), _f(frac) if v is None or base is None or fr is None: return None if base <= 0 or v < 0 or fr < FRAC_MIN: return None raw = (v / 100.0) / base if raw > VOL_SANITY_MAX: return None return round(raw / fr, 3) def in_window(now: datetime, window: str) -> bool: """窗口写成 "0945-1430" 这种。写坏了返回 True (不因为一个格式错误把整条路堵死)。""" try: a, b = str(window or "").split("-") lo = int(a[:2]) * 60 + int(a[2:]) hi = int(b[:2]) * 60 + int(b[2:]) except Exception: return True return lo <= _minutes(now) <= hi def _u1(base, now_read) -> tuple: """择时决策系统当日判过转多, 且晚于驳回时刻。""" at = str((now_read or {}).get("bionic_buy_at") or "") base_at = str((base or {}).get("at") or "") if not at: return False, "U1 当日没有转多留痕" if not base_at: return False, "U1 驳回时刻读不到, 判不了先后" if at <= base_at: return False, "U1 转多留痕在驳回之前, 研判当时已经看过" return True, "决策系统 %s 判这只票转多 (晚于 %s 的驳回)" % (at[11:16] or at, base_at[11:16] or base_at) def _u2(base, now_read, drift_max) -> tuple: """昨夜结论被盘中重算改写且方向向好。""" y0 = str((base or {}).get("y_signal") or "").upper() y1 = str((now_read or {}).get("y_signal") or "").upper() good = ("BUY", "MAINTAIN") if y1 and y1 in good and y0 and y0 not in good: return True, "昨夜定性由 %s 改写成 %s" % (y0, y1) for k, label in (("support", "支撑"), ("pressure", "压力")): a, b = _f((base or {}).get(k)), _f((now_read or {}).get(k)) if a and b and a > 0: d = abs(b / a - 1.0) if d > drift_max: return True, "%s位由 %.2f 漂到 %.2f (%.1f%%, 超过 %.0f%%)" % ( label, a, b, d * 100, drift_max * 100) return False, "U2 底牌没被改写" def _u3(base, now_read, up_min, vol_min, vol_mult) -> tuple: """价升且量能确认, 三条并列。""" p0, p1 = _f((base or {}).get("price")), _f((now_read or {}).get("price")) if not p0 or not p1 or p0 <= 0: return False, "U3 价格读不到" up = p1 / p0 - 1.0 if up < up_min: return False, "U3 较驳回只走了 %.2f%% (要 %.0f%%)" % (up * 100, up_min * 100) v0, v1 = _f((base or {}).get("vol_ratio")), _f((now_read or {}).get("vol_ratio")) if v1 is None: return False, "U3 量比取不到" if v1 < vol_min: return False, "U3 量比 %.2f 不到 %.1f" % (v1, vol_min) if v0 is None or v0 <= 0: return False, "U3 驳回时的量比没记下, 判不了是不是相对变强" if v1 < v0 * vol_mult: return False, "U3 量比 %.2f 没到驳回时 %.2f 的 %.1f 倍" % (v1, v0, vol_mult) return True, "现价 %.2f→%.2f (+%.2f%%), 时段折算量比 %.2f→%.2f" % (p0, p1, up * 100, v0, v1) def evaluate(*, base: dict, now_read: dict, params: dict, used_today: int, now: datetime) -> dict: """唯一的判定函数。零副作用, 不读库不写库 —— 取数由调用方备齐。 返回 {"unlock": bool, "hits": [...], "why": 一句中文, "blocked_by": "G1"|None, "notes": [...]} """ notes = [] if not params.get("enabled", True): return {"unlock": False, "hits": [], "why": "解锁重问总开关关着", "blocked_by": "G0", "notes": notes} gap_min = int(params.get("min_gap_min", 20)) base_at = str((base or {}).get("at") or "") if not base_at: # 旧的驳回行没有基线快照 (这个块是今天才开始写的)。U1、U2 还能判, U3 判不了。 notes.append("这条驳回行没有基线快照,U3 用不了") else: try: t0 = datetime.fromisoformat(base_at) if (now - t0).total_seconds() < gap_min * 60: return {"unlock": False, "hits": [], "blocked_by": "G1", "notes": notes, "why": "距驳回还不到 %d 分钟" % gap_min} except ValueError: notes.append("驳回时刻格式认不出:" + base_at) if used_today >= int(params.get("max_per_day", 1)): return {"unlock": False, "hits": [], "blocked_by": "G2", "notes": notes, "why": "今天已经重问过 %d 次,一天就这一次" % used_today} if not in_window(now, params.get("window", "0945-1430")): return {"unlock": False, "hits": [], "blocked_by": "G3", "notes": notes, "why": "不在重问时段 %s 内" % params.get("window", "0945-1430")} hits, whys, misses = [], [], [] for tag, fn in (("U1", lambda: _u1(base, now_read)), ("U2", lambda: _u2(base, now_read, float(params.get("ref_drift", 0.03)))), ("U3", lambda: _u3(base, now_read, float(params.get("up_min", 0.02)), float(params.get("vol_min", 1.5)), float(params.get("vol_mult", 1.3))))): try: ok, why = fn() except Exception as e: # 判定本身出错也算不成立 ok, why = False, "%s 判定出错: %s" % (tag, e) (whys if ok else misses).append(why) if ok: hits.append(tag) if not hits: return {"unlock": False, "hits": [], "blocked_by": None, "notes": notes, "why": "输入没有向好的变化:" + ";".join(misses)} return {"unlock": True, "hits": hits, "blocked_by": None, "notes": notes, "why": ";".join(whys)}