# -*- coding: utf-8 -*- """解锁重问 (2026-09-09 方案第四之一节, 台账 055)。全部离线, 不连库不连 Redis 不调模型。 A 纯逻辑: 时段占比的四个边界、量比的单位换算与两条哨兵、窗口解析。 B 四条护栏: 总开关、二十分钟间隔、当日一次、时段窗口, 各自的 blocked_by。 C 三条解锁条件: U1 的先后与发送方、U2 的定性翻转与漂移边界、U3 的三条并列与 fail-closed。 D 请求体: force 为假时与今天逐字节相同; 为真时只多两个键; 白名单收了那一句。 E 名额与落账: 名额只在真发请求时用掉、试算不落账、规则闸拦下不消耗、重问后仍驳回加前缀。 F 分流: 重问放行强制交人, 哪怕档位 full。 G 参数与页面: 九个键登记齐、页面认得新动作名。 """ import io import os import sys import traceback from datetime import datetime, timedelta sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.core import reask_rules as rr # noqa: E402 from app.services import param_store # noqa: E402 from app.services import reask_service as rs # noqa: E402 RESULTS = [] def case(name): def deco(fn): RESULTS.append((name, fn)) return fn return deco class _Patch: def __init__(self): self._saved = [] def __enter__(self): return self def __call__(self, obj, name, value): self._saved.append((obj, name, getattr(obj, name))); setattr(obj, name, value) def __exit__(self, *a): for obj, name, v in reversed(self._saved): setattr(obj, name, v) def _t(h, m): return datetime(2026, 9, 9, h, m, 0) P = {"enabled": True, "min_gap_min": 20, "max_per_day": 1, "window": "0945-1430", "ref_drift": 0.03, "up_min": 0.02, "vol_min": 1.5, "vol_mult": 1.3} def _base(**kw): b = {"at": "2026-09-09T10:12:00", "price": 43.15, "chg_prev": 0.076, "chg_open": 0.041, "vol_ratio": 1.10, "judge_conf": 72, "y_signal": "WATCH", "support": 40.00, "pressure": 42.00} b.update(kw) return b def _now(**kw): n = {"price": 43.15, "vol_ratio": 1.10, "y_signal": "WATCH", "support": 40.00, "pressure": 42.00, "bionic_buy_at": ""} n.update(kw) return n @case("A 时段占比: 盘前 0、上午末 0.5、午休不走、收盘 1") def test_frac(): assert rr.elapsed_trading_frac(_t(9, 0)) == 0.0 assert rr.elapsed_trading_frac(_t(9, 30)) == 0.0 assert abs(rr.elapsed_trading_frac(_t(10, 30)) - 60 / 240.0) < 1e-9 assert abs(rr.elapsed_trading_frac(_t(11, 30)) - 0.5) < 1e-9 assert abs(rr.elapsed_trading_frac(_t(12, 0)) - 0.5) < 1e-9, "午休不该往前走" assert abs(rr.elapsed_trading_frac(_t(13, 0)) - 0.5) < 1e-9 assert rr.elapsed_trading_frac(_t(15, 0)) == 1.0 assert rr.elapsed_trading_frac(_t(16, 0)) == 1.0 @case("A 量比: 手与股差 100 倍的换算、两条哨兵、覆盖太短不判") def test_vol_ratio(): # 1736 万股 = 17.36 万手; 五日均量 18.5 万手; 覆盖 0.829 → 1.128 (09-09 四方股份实测值) assert rr.vol_ratio(17361626, 185554.7, 0.8292) == 1.128, rr.vol_ratio(17361626, 185554.7, 0.8292) assert rr.vol_ratio(100 * 185554.7, 185554.7, 1.0) == 1.0, "整整一份日均量、走完全天 = 1.0" assert rr.vol_ratio(17361626, 185554.7, 0.05) is None, "覆盖不足 8% 不判" assert rr.vol_ratio(100 * 185554.7 * 31, 185554.7, 1.0) is None, "原始比超 30 判数据异常" for bad in ((None, 1.0, 1.0), (1.0, None, 1.0), (1.0, 1.0, None), (1.0, 0, 1.0), (-1, 1.0, 1.0)): assert rr.vol_ratio(*bad) is None, bad @case("A 窗口解析: 边界含端点; 格式写坏了不堵路") def test_window(): assert rr.in_window(_t(9, 45), "0945-1430") and rr.in_window(_t(14, 30), "0945-1430") assert not rr.in_window(_t(9, 44), "0945-1430") and not rr.in_window(_t(14, 31), "0945-1430") assert rr.in_window(_t(9, 0), "写坏了"), "格式错不该把整条路堵死" @case("B 四条护栏各自挡住并说清是哪一条") def test_gates(): off = dict(P, enabled=False) r = rr.evaluate(base=_base(), now_read=_now(), params=off, used_today=0, now=_t(11, 0)) assert r["blocked_by"] == "G0" and not r["unlock"] r = rr.evaluate(base=_base(), now_read=_now(), params=P, used_today=0, now=_t(10, 25)) assert r["blocked_by"] == "G1" and not r["unlock"], r r = rr.evaluate(base=_base(), now_read=_now(), params=P, used_today=1, now=_t(11, 0)) assert r["blocked_by"] == "G2" and not r["unlock"], r r = rr.evaluate(base=_base(), now_read=_now(), params=P, used_today=0, now=_t(14, 45)) assert r["blocked_by"] == "G3" and not r["unlock"], r # 20 分钟整点即可 r = rr.evaluate(base=_base(), now_read=_now(), params=P, used_today=0, now=_t(10, 32)) assert r["blocked_by"] is None, r @case("C U1: 晚于驳回才算; 早于驳回是循环论证; 没有留痕不成立") def test_u1(): late = rr.evaluate(base=_base(), now_read=_now(bionic_buy_at="2026-09-09T10:40:00"), params=P, used_today=0, now=_t(11, 0)) assert late["unlock"] and late["hits"] == ["U1"], late early = rr.evaluate(base=_base(), now_read=_now(bionic_buy_at="2026-09-09T10:04:00"), params=P, used_today=0, now=_t(11, 0)) assert not early["unlock"] and "循环论证" not in early["why"], early none = rr.evaluate(base=_base(), now_read=_now(), params=P, used_today=0, now=_t(11, 0)) assert not none["unlock"] # 驳回时刻读不到时判不了先后 → 不成立 (fail-closed) nb = rr.evaluate(base=_base(at=""), now_read=_now(bionic_buy_at="2026-09-09T10:40:00"), params=P, used_today=0, now=_t(11, 0)) assert not nb["unlock"] and nb["notes"], nb @case("C U2: 定性翻成看多、支撑压力漂过 3%; 2.9% 不算") def test_u2(): up = rr.evaluate(base=_base(), now_read=_now(y_signal="BUY"), params=P, used_today=0, now=_t(11, 0)) assert up["unlock"] and up["hits"] == ["U2"], up assert not rr.evaluate(base=_base(y_signal="BUY"), now_read=_now(y_signal="BUY"), params=P, used_today=0, now=_t(11, 0))["unlock"], "本来就是 BUY 不算改写" d29 = rr.evaluate(base=_base(), now_read=_now(support=40.00 * 1.029), params=P, used_today=0, now=_t(11, 0)) assert not d29["unlock"], d29 d31 = rr.evaluate(base=_base(), now_read=_now(support=40.00 * 1.031), params=P, used_today=0, now=_t(11, 0)) assert d31["unlock"] and d31["hits"] == ["U2"], d31 dn = rr.evaluate(base=_base(), now_read=_now(pressure=42.00 * 0.96), params=P, used_today=0, now=_t(11, 0)) assert dn["unlock"], "往下漂同样是底牌变了" @case("C U3: 三条并列才算; 少一条就不解锁; 读不到一律不成立") def test_u3(): ok = rr.evaluate(base=_base(), now_read=_now(price=44.10, vol_ratio=1.60), params=P, used_today=0, now=_t(11, 0)) assert ok["unlock"] and ok["hits"] == ["U3"], ok # 只涨不放量 assert not rr.evaluate(base=_base(), now_read=_now(price=44.10, vol_ratio=1.20), params=P, used_today=0, now=_t(11, 0))["unlock"] # 放量但没涨够 (09-09 四方股份下午就是这个形态的反面: 量比 1.128 且价格还跌了) assert not rr.evaluate(base=_base(), now_read=_now(price=43.50, vol_ratio=1.60), params=P, used_today=0, now=_t(11, 0))["unlock"] # 量比够 1.5 但没到驳回时的 1.3 倍 assert not rr.evaluate(base=_base(vol_ratio=1.40), now_read=_now(price=44.10, vol_ratio=1.55), params=P, used_today=0, now=_t(11, 0))["unlock"] # fail-closed: 量比读不到、驳回时没记量比、价格读不到, 三种都不成立 for nr, bs in ((_now(price=44.10, vol_ratio=None), _base()), (_now(price=44.10, vol_ratio=1.60), _base(vol_ratio=None)), (_now(price=None, vol_ratio=1.60), _base())): r = rr.evaluate(base=bs, now_read=nr, params=P, used_today=0, now=_t(11, 0)) assert not r["unlock"], (r, nr, bs) @case("C 多条同时成立时都记下来 (复盘要知道是哪几条一起促成的)") def test_multi_hit(): r = rr.evaluate(base=_base(), now_read=_now(price=44.10, vol_ratio=1.60, y_signal="BUY", bionic_buy_at="2026-09-09T10:40:00"), params=P, used_today=0, now=_t(11, 0)) assert r["unlock"] and set(r["hits"]) == {"U1", "U2", "U3"}, r @case("D 请求体: force 为假时与今天逐字节相同; 为真时只多两个键") def test_payload(): from app.services import judge as jd cand = {"ts_code": "601126.SH", "action": "OPEN", "qty": 100, "reason": "x", "hard_numbers": {"price": 43.15}} seen = {} class _Resp: status_code = 200 def raise_for_status(self): pass def json(self): return {"verdict": "PASS", "reason": "ok", "confidence": 80} import types fake_requests = types.SimpleNamespace(post=lambda url, json=None, timeout=None: (seen.setdefault(len(seen), json), _Resp())[1]) with _Patch() as p: p(jd, "available", lambda: True) p(jd, "base_url", lambda: "http://x") sys.modules["requests"] = fake_requests jd.request(dict(cand)) jd.request(dict(cand), force=True, reask_text="曾驳回于 43.15,现价 44.10") plain, forced = seen[0], seen[1] assert "force_refresh" not in plain and "reask_text" not in plain, plain assert forced["force_refresh"] is True and forced["reask_text"].startswith("曾驳回") assert set(forced) - set(plain) == {"force_refresh", "reask_text"}, set(forced) - set(plain) for k in plain: assert plain[k] == forced[k] or k == "hard_numbers", k assert "reask_text" in jd.OPEN_JUDGE_KEYS @case("E 名额: 真发请求才落账; 落账失败就不算重问; 动作名不与建新仓混") def test_commit(): calls = [] with _Patch() as p: p(rs.pms_repo, "insert_ledger", lambda **kw: calls.append(kw) or 1) assert rs.commit("601126.SH", {"hits": ["U3"], "why": "价升量增"}, 44.1) is True assert calls[0]["action"] == "OPEN_REASK" != "OPEN", calls[0] assert calls[0]["verdict"] == "NOTE" and calls[0]["arbiter"] == "rule" assert "U3" in calls[0]["reason"] and calls[0]["hard_numbers"]["reask"]["hits"] == ["U3"] def boom(**kw): raise RuntimeError("表写不进") with _Patch() as p: p(rs.pms_repo, "insert_ledger", boom) assert rs.commit("601126.SH", {}, 1.0) is False, "落账失败必须回 False, 让调用方别重问" @case("E 判定零副作用: evaluate_all 一行都不写") def test_readonly(): wrote = [] with _Patch() as p: p(rs.pms_repo, "insert_ledger", lambda **kw: wrote.append(kw)) p(rs.pms_repo, "judge_rejected_open_rows", lambda since: {"601126.SH": {"at": "2026-09-09 10:12:00", "price": 43.15, "hard": {"reask_base": _base()}}}) p(rs.pms_repo, "reask_count_today", lambda since: {}) p(rs.pms_repo, "buy_signals_today", lambda since: {}) p(rs, "_now_read", lambda code, at: _now(price=44.10, vol_ratio=1.60)) p(rs, "params", lambda: P) out = rs.evaluate_all({("601126.SH", "OPEN"): "驳回"}, now=_t(11, 0)) assert ("601126.SH", "OPEN") in out and out[("601126.SH", "OPEN")]["hits"] == ["U3"], out assert not wrote, "解锁判定每分钟都在跑, 一行都不许写" @case("E 读账本失败一律不解锁 (宁可漏, 不可误)") def test_repo_fail(): def boom(since): raise RuntimeError("库抖") with _Patch() as p: p(rs.pms_repo, "judge_rejected_open_rows", boom) p(rs, "params", lambda: P) assert rs.evaluate_all({("601126.SH", "OPEN"): "驳回"}, now=_t(11, 0)) == {} @case("E 只认决策系统发的转多留痕 (别的来源不算 U1)") def test_u1_sender(): with _Patch() as p: p(rs.pms_repo, "judge_rejected_open_rows", lambda since: {"601126.SH": {"at": "2026-09-09 10:12:00", "price": 43.15, "hard": {"reask_base": _base()}}}) p(rs.pms_repo, "reask_count_today", lambda since: {}) p(rs.pms_repo, "buy_signals_today", lambda since: {"601126.SH": {"at": "2026-09-09 10:40:00", "reason": "别处发来的看多"}}) p(rs, "_now_read", lambda code, at: _now(bionic_buy_at=at)) p(rs, "params", lambda: P) assert rs.evaluate_all({("601126.SH", "OPEN"): "驳回"}, now=_t(11, 0)) == {}, "发送方不对不算" @case("F 重问放行强制交人; 重问仍驳回加前缀且带次序") def test_route(): src = io.open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "app/services/proposal_service.py"), encoding="utf-8").read() # 2026-09-10 改了断言的写法, 意图不变。原先写的是 `'or bool(reask_why))' in src`, # 它把「reask_why 必须是那一行的最后一项」也一起钉住了 —— 而那只是当时的排版, # 不是这条规矩的内容。同日往一票否决里加「上游标了硬风险的候选也交人」之后, # 那一行结尾从 `bool(reask_why))` 变成 `bool(risk_why))`, 这条断言就红了, # 而被它保护的行为一个字都没变。现在改成取出那一行、只看 reask_why 在不在里面。 _fq = src[src.index("force_queue = ("):src.index("auto_exec = (not force_queue)")] assert "bool(reask_why)" in _fq, "重问必须进 force_queue 一票否决" # 2026-09-10 往这条链里插了「上游标了硬风险」一档, 排在重问之后 —— 重问仍是最前一项。 assert src.index("why = (reask_why") > 0, "重问的原因要排在最前, 人最需要知道它" assert '"重问后仍驳回:" + _rsn' in src and '_hn["reask_seq"] = 1' in src assert 'reask_service.commit(code, _rk, price)' in src i = src.index("_rk = c.get(\"reask\") if not dry_run else None") j = src.index("verdict = judge.request(c,") assert "dry_run" in src[i:i + 60], "试算一律不落账不带强制标记" assert j > i, "名额必须在发请求之前落" # 规则闸在研判闸前面, 且被规则闸拦下的直接 return —— 不会走到落名额那一步 assert src.index('if not gate["passed"]:') < i @case("G 九个参数登记齐、有范围、页面认得新动作名") def test_params_and_page(): keys = ["PMS_OPEN_REASK_ENABLED", "PMS_OPEN_REASK_MIN_GAP_MIN", "PMS_OPEN_REASK_MAX_PER_DAY", "PMS_OPEN_REASK_WINDOW", "PMS_OPEN_REASK_REF_DRIFT", "PMS_OPEN_REASK_UP", "PMS_OPEN_REASK_VOL_MIN", "PMS_OPEN_REASK_VOL_MULT", "PMS_JUDGE_REJECT_CONF_MIN"] for k in keys: assert k in param_store.RUNTIME_EXTRA, k assert param_store.RUNTIME_EXTRA[k][2], k + " 没有中文说明" for k in keys: if param_store.RUNTIME_EXTRA[k][1] in (int, float): assert k in param_store._RANGES, k + " 没有范围" p = rs.params() assert set(p) == {"enabled", "min_gap_min", "max_per_day", "window", "ref_drift", "up_min", "vol_min", "vol_mult"}, p from app.web import main as web html = io.open(os.path.join(web.STATIC_DIR, "index.html"), encoding="utf-8").read() assert "OPEN_REASK:'重问研判'" in html, "页面不认这个动作名就会把英文原码印给人看" def main(): ok = 0 for name, fn in RESULTS: try: fn() ok += 1 print(" ok " + name) except Exception: print(" FAIL " + name) traceback.print_exc() print("-" * 60) if ok == len(RESULTS): print("ALL PASS (%d cases)" % ok) return 0 print("FAILED %d/%d" % (len(RESULTS) - ok, len(RESULTS))) return 1 if __name__ == "__main__": sys.exit(main())