tradingSystem/app/core/reask_rules.py

213 lines
10 KiB
Python
Raw Permalink 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 -*-
"""研判驳回后的「解锁重问」判定 (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)}