# -*- coding: utf-8 -*-
"""
第十二批模块单测 (实机运行, 零外部依赖, 不连库)
==================================================
运行: 在 tradingSystem 仓库根目录执行  python scripts/test_batch12_units.py
覆盖: 自主新建仓 (动作引擎 OPEN) 的选票、数量、名额与金额的滚动扣减、行业集中度滚动;
      研判请求的硬数字裁剪 (仓位数字不送决策系统);
      参考位漂移的当日首答锁定与偏离即停;
      研判的单轮时间预算。
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
"""
import os
import sys
import traceback

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from app.core import action_engine as ae                                       # noqa: E402
from app.services import exec_advisor as ea                                    # noqa: E402
from app.services import judge as jd                                           # noqa: E402
from app.services import param_store as ps                                     # noqa: E402

RESULTS = []


def case(name):
    def deco(fn):
        RESULTS.append((name, fn))
        return fn
    return deco


# ================================================================ 夹具
def params(**kw):
    """action_engine 那侧要的参数快照 (口径同 param_store.sizing_params)。"""
    p = {"scale": 2_000_000.0, "stock_target_default": 0.06,
         "batch_split": (0.5, 0.25, 0.25), "min_lot_merge": True}
    p.update(kw)
    return p


def caps(**kw):
    """portfolio.caps_ctx 的产出形状 (组合快照)。"""
    c = {"scale": 2_000_000.0, "portfolio_cap": 0.70, "stock_cap": 0.08,
         "max_names": 20, "portfolio_mv": 0.0, "names_count": 0,
         "stock_mv": 0.0, "is_new_name": False, "sector": None,
         "sector_names": 0, "sector_mv": 0.0,
         "sector_names_map": {}, "sector_mv_map": {},
         "sector_max_names": 4, "sector_max_ratio": 0.40,
         "cash_reserve": 0.0, "sector_source_ready": True,
         "cash_avail": None, "cash_source": "estimate"}
    c.update(kw)
    return c


def cand(code, price=10.0, score=100.0, sector=None, rank=1, **kw):
    d = {"ts_code": code, "price": price, "score": score, "sector": sector,
         "theme": kw.pop("theme", None), "tier": kw.pop("tier", "强传导"),
         "upside": kw.pop("upside", 0.3), "heat": kw.pop("heat", 0.5),
         "rank": rank, "bucket": "main", "src": "plan_api"}
    d.update(kw)
    return d


class _PatchParams:
    """把 param_store 的取值函数临时换成一份字典 —— 单测不连库。"""

    def __init__(self, values):
        self.values = values
        self.saved = {}

    def __enter__(self):
        for name in ("get", "get_int", "get_float", "get_bool"):
            self.saved[name] = getattr(ps, name)
        v = self.values
        ps.get = lambda k, d=None: v.get(k, d)
        ps.get_int = lambda k, d=0: int(v.get(k, d))
        ps.get_float = lambda k, d=0.0: float(v.get(k, d))
        ps.get_bool = lambda k, d=False: bool(v.get(k, d))
        return self

    def __exit__(self, *a):
        for name, fn in self.saved.items():
            setattr(ps, name, fn)
        return False


# ================================================================ 动作引擎: 新建仓
@case("新建仓·正常产出: 只提底仓批, 数量按 50% 取整到一手")
def _():
    # 目标 6% × 200 万 = 12 万; 底仓 50% = 6 万; 现价 10 元 → 6000 股
    r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(),
                     room_amt=1_400_000, slots=20)
    assert len(r["candidates"]) == 1, r
    c = r["candidates"][0]
    assert c["action"] == "OPEN" and c["side"] == "buy", c
    assert c["qty"] == 6000, c
    assert c["judge_required"] is True, "OPEN 必须有资格送研判闸"
    assert c["needs_user_confirm"] is False, "新建仓不强制人工确认 (档位说了算)"
    # 现价与行业必须挂在候选上 —— 新票在账本里没有行, 服务层取不到这两样
    assert c["price"] == 10.0 and "sector" in c, c
    hn = c["hard_numbers"]
    assert hn["score"] == 100.0 and hn["price"] == 10.0, hn
    assert hn["target_amount"] == 120000.0, hn        # 三批取整后正好 12 万
    assert hn["base_amount"] == 60000.0, hn


@case("新建仓·没有名额时一条不产, 且说得出为什么")
def _():
    r = ae.scan_open(candidates=[cand("600000.SH")], params=params(),
                     caps=caps(names_count=20), room_amt=1_400_000, slots=0)
    assert r["candidates"] == [], r
    assert "名额" in r["skipped"][0]["why"], r["skipped"]


@case("新建仓·没有可投金额时一条不产, 且说得出为什么")
def _():
    r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(),
                     room_amt=0, slots=20)
    assert r["candidates"] == [], r
    assert "可投金额" in r["skipped"][0]["why"], r["skipped"]


@case("新建仓·取不到实时价整只跳过并留痕 (昨收不能拿来下单)")
def _():
    r = ae.scan_open(candidates=[cand("600000.SH", price=None)], params=params(),
                     caps=caps(), room_amt=1_400_000, slots=20)
    assert r["candidates"] == [], r
    assert "实时价" in r["skipped"][0]["why"], r["skipped"]


@case("新建仓·底仓批买不足一手 → 不提, 原因是一手不是别的")
def _():
    # 目标 6% × 10 万 = 6000 元; 现价 500 元一手就要 5 万 → 拆不出
    r = ae.scan_open(candidates=[cand("600000.SH", price=500.0)],
                     params=params(scale=100_000.0), caps=caps(scale=100_000.0),
                     room_amt=70_000, slots=20)
    assert r["candidates"] == [], r
    assert "一手" in r["skipped"][0]["why"], r["skipped"]


@case("新建仓·名额边走边扣: 只剩一个名额就只产一条, 其余留到下一跳")
def _():
    cs = [cand("600000.SH", score=300, rank=1), cand("600001.SH", score=200, rank=2),
          cand("600002.SH", score=100, rank=3)]
    r = ae.scan_open(candidates=cs, params=params(), caps=caps(names_count=19),
                     room_amt=1_400_000, slots=1)
    assert [c["ts_code"] for c in r["candidates"]] == ["600000.SH"], r["candidates"]
    whys = [s["why"] for s in r["skipped"]]
    assert any("名额已用完" in w for w in whys), whys


@case("新建仓·金额边走边扣: 三条候选只够两条, 第三条被金额挡下")
def _():
    # 每条整只目标 12 万; 可投 25 万 → 两条 (24 万) 之后只剩 1 万, 不够一整只
    cs = [cand("600000.SH", score=300, rank=1), cand("600001.SH", score=200, rank=2),
          cand("600002.SH", score=100, rank=3)]
    r = ae.scan_open(candidates=cs, params=params(), caps=caps(),
                     room_amt=250_000, slots=20)
    assert [c["ts_code"] for c in r["candidates"]] == ["600000.SH", "600001.SH"], r["candidates"]
    whys = " ".join(s["why"] for s in r["skipped"])
    assert "不开半截新仓" in whys, whys


@case("新建仓·钱不够一整只就不开 (不拿名额去换零头仓位)")
def _():
    # 目标 12 万, 只剩 5 万 —— 拆得出一手批次, 但那是个补不到目标的零头仓位
    r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(),
                     room_amt=50_000, slots=20)
    assert r["candidates"] == [], r
    assert "不开半截新仓" in r["skipped"][0]["why"], r["skipped"]


@case("新建仓·滚动扣减真的生效: 组合上限只容得下两条时不会三条都放出来")
def _():
    # 总仓上限 70% × 200 万 = 140 万, 已有 116 万 → 只剩 24 万 = 两只
    # 如果不滚动 (每条都拿同一份旧快照算), 三条会各自都通过 —— 这一例就是为它写的
    cs = [cand(f"60000{i}.SH", score=300 - i, rank=i + 1) for i in range(3)]
    r = ae.scan_open(candidates=cs, params=params(),
                     caps=caps(portfolio_mv=1_160_000, names_count=5),
                     room_amt=240_000, slots=20)
    assert len(r["candidates"]) == 2, [c["ts_code"] for c in r["candidates"]]


@case("新建仓·行业集中度也滚动: 同行业名额只剩一个时第二只被拦")
def _():
    cs = [cand("600000.SH", score=300, rank=1, sector="储能"),
          cand("600001.SH", score=200, rank=2, sector="储能")]
    r = ae.scan_open(candidates=cs, params=params(),
                     caps=caps(sector_max_names=1), room_amt=1_400_000, slots=20)
    assert len(r["candidates"]) == 1, [c["ts_code"] for c in r["candidates"]]
    whys = " ".join(s["why"] for s in r["skipped"])
    assert "SECTOR_NAMES" in whys, whys


@case("新建仓·行业数据源没配时不误拦 (sector 一律按空处理)")
def _():
    cs = [cand("600000.SH", score=300, rank=1, sector="储能"),
          cand("600001.SH", score=200, rank=2, sector="储能")]
    r = ae.scan_open(candidates=cs, params=params(),
                     caps=caps(sector_max_names=1, sector_source_ready=False),
                     room_amt=1_400_000, slots=20)
    assert len(r["candidates"]) == 2, [c["ts_code"] for c in r["candidates"]]


@case("新建仓·已有在途或今日被拒过的票不重复提")
def _():
    r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(),
                     room_amt=1_400_000, slots=20, skip={("600000.SH", "OPEN")})
    assert r["candidates"] == [], r
    assert "在途" in r["skipped"][0]["why"], r["skipped"]


@case("跳过原因·字典形式把四种处境分得开 (在途 / 规则闸 / 研判闸)")
def _():
    skip = {
        ("600000.SH", "OPEN"): "已有在途指令 INS_20260806_600000SH_OPEN_001 (DISPATCHED)",
        ("600001.SH", "OPEN"): "今日已被规则闸拒过 (日切后重新评估; 拒因看 make t-gate)",
        ("600002.SH", "OPEN"): "今日已被研判闸驳回 (日切后重新评估; 驳回理由看 make t-gate)",
    }
    cs = [cand("600000.SH", score=300, rank=1), cand("600001.SH", score=200, rank=2),
          cand("600002.SH", score=100, rank=3)]
    r = ae.scan_open(candidates=cs, params=params(), caps=caps(),
                     room_amt=1_400_000, slots=20, skip=skip)
    assert r["candidates"] == [], r
    got = {s["ts_code"]: s["why"] for s in r["skipped"]}
    assert "在途指令" in got["600000.SH"] and "DISPATCHED" in got["600000.SH"], got
    assert "规则闸" in got["600001.SH"] and "日切后重新评估" in got["600001.SH"], got
    assert "研判闸" in got["600002.SH"] and "日切后重新评估" in got["600002.SH"], got
    # 「明天还会不会再评估」这个问题, 光看这一行就该答得出来
    assert "日切后重新评估" not in got["600000.SH"], "在途的明天照样被挡, 别写成会重来"


@case("跳过原因·传集合进来仍然工作 (旧口径的向后兼容)")
def _():
    r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(),
                     room_amt=1_400_000, slots=20, skip={("600000.SH", "OPEN")})
    assert r["skipped"][0]["why"] == ae.SKIP_WHY_FALLBACK, r["skipped"]
    assert ae.skip_why({("A", "OPEN")}, ("A", "OPEN")) == ae.SKIP_WHY_FALLBACK
    assert ae.skip_why({("A", "OPEN")}, ("B", "OPEN")) is None
    assert ae.skip_why(None, ("A", "OPEN")) is None
    # 字典里值是空的也不能当成"没被跳过"
    assert ae.skip_why({("A", "OPEN"): ""}, ("A", "OPEN")) == ae.SKIP_WHY_FALLBACK


@case("跳过原因·已有四类动作也跟着分得开 (scan 与 scan_open 同一套)")
def _():
    pos = [{"ts_code": "600000.SH", "total_qty": 1000, "avail_qty": 1000,
            "price": 10.0, "price_ok": True, "cushion_pct": 0.05, "cushion_peak": 0.10,
            "frozen_reason": "NONE", "base_qty": 1000}]
    skip = {("600000.SH", "TRIM"): "已有在途指令 INS_x (DISPATCHED)"}
    r = ae.scan(positions=pos, params=params(), market={}, skip=skip)
    trim = [s for s in r["skipped"] if s["action"] == "TRIM"]
    assert trim and "在途指令" in trim[0]["why"], r["skipped"]


@case("新建仓·单票评估异常不拖垮整轮, 后面的票照常评")
def _():
    orig = ae.eval_open

    def boom(c, *a, **kw):
        if c["ts_code"] == "600000.SH":
            raise RuntimeError("求值炸了")
        return orig(c, *a, **kw)

    ae.eval_open = boom
    try:
        cs = [cand("600000.SH", score=300, rank=1), cand("600001.SH", score=200, rank=2)]
        r = ae.scan_open(candidates=cs, params=params(), caps=caps(),
                         room_amt=1_400_000, slots=20)
    finally:
        ae.eval_open = orig
    assert [c["ts_code"] for c in r["candidates"]] == ["600001.SH"], r["candidates"]
    whys = " ".join(s["why"] for s in r["skipped"])
    assert "评估异常" in whys and "RuntimeError" in whys, whys


@case("转多信号·插队排最前: 分数低但有信号的票抢在前面")
def _():
    cs = [cand("600000.SH", score=300, rank=1),
          cand("600001.SH", score=200, rank=2),
          dict(cand("600002.SH", score=100, rank=3), sig_buy={"reason": "盘中判转多"})]
    r = ae.scan_open(candidates=cs, params=params(), caps=caps(names_count=19),
                     room_amt=1_400_000, slots=1)
    assert [c["ts_code"] for c in r["candidates"]] == ["600002.SH"], r["candidates"]
    c = r["candidates"][0]
    assert c["hard_numbers"]["intraday_buy_signal"] is True, c["hard_numbers"]
    assert c["hard_numbers"]["intraday_buy_reason"] == "盘中判转多"
    assert "转多" in c["reason"], c["reason"]


@case("转多信号·开关关掉就退回纯分数排序")
def _():
    cs = [cand("600000.SH", score=300, rank=1),
          dict(cand("600002.SH", score=100, rank=3), sig_buy={"reason": "盘中判转多"})]
    r = ae.scan_open(candidates=cs, params=params(open_signal_priority=False),
                     caps=caps(names_count=19), room_amt=1_400_000, slots=1)
    assert [c["ts_code"] for c in r["candidates"]] == ["600000.SH"], r["candidates"]


@case("转多信号·只改先后不改资格: 没信号的票照样能建, 有信号也过同样的闸")
def _():
    # 有信号但组合没名额 —— 一样建不了, 信号不是免死金牌
    r = ae.scan_open(candidates=[dict(cand("600002.SH"), sig_buy={"reason": "转多"})],
                     params=params(), caps=caps(names_count=20), room_amt=1_400_000, slots=0)
    assert r["candidates"] == [], r
    # 没信号的票照常建
    r2 = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(),
                      room_amt=1_400_000, slots=20)
    assert len(r2["candidates"]) == 1
    assert r2["candidates"][0]["hard_numbers"]["intraday_buy_signal"] is False


@case("信号消化·未持仓票的买入信号也要留痕 (从前它连账本都没有)")
def _():
    from app.core import signal_rules as sr
    sig = {"source": "intraday", "ts_code": "600002.SH", "action": "BUY",
           "confidence": 0.95, "suggested_price": 12.3, "reason": "放量突破"}
    d = sr.digest(sig, None, {"sell_conf_min": 0.75, "auto_exit_conf": 0.85})
    assert d["action"] == sr.ACT_NOTE_BUY, d
    assert d["qty"] == 0, "留痕不产生任何数量"
    assert "无持仓" in d["reason"] and "只留痕" in d["reason"], d["reason"]
    assert d["hard_numbers"]["suggested_price"] == 12.3


@case("信号消化·持仓票的买入信号同样走留痕, 且标明有持仓")
def _():
    from app.core import signal_rules as sr
    sig = {"source": "intraday", "ts_code": "600000.SH", "action": "BUY", "confidence": 0.95}
    d = sr.digest(sig, {"total_qty": 1000}, {})
    assert d["action"] == sr.ACT_NOTE_BUY and d["hard_numbers"]["held"] == 1000, d
    assert "有持仓" in d["reason"], d["reason"]


@case("信号消化·卖出与 HOLD 的口径一个字没变")
def _():
    from app.core import signal_rules as sr
    prm = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3}
    # HOLD 仍走老的 RECORD
    d = sr.digest({"source": "intraday", "ts_code": "600000.SH", "action": "HOLD"}, None, prm)
    assert d["action"] == sr.ACT_RECORD, d
    # 高置信卖出仍是清仓
    d = sr.digest({"source": "risk_sell", "ts_code": "600000.SH", "action": "SELL",
                   "confidence": 0.9}, {"total_qty": 1000, "avail_qty": 1000}, prm)
    assert d["action"] == sr.ACT_EXIT and d["qty"] == 1000, d
    # 未持有的卖出信号仍然忽略
    d = sr.digest({"source": "risk_sell", "ts_code": "600000.SH", "action": "SELL",
                   "confidence": 0.9}, None, prm)
    assert d["action"] == sr.ACT_IGNORE, d


@case("研判请求·转多信号是定性材料, 要送过去; 仓位数字照旧不送")
def _():
    hard = {"price": 10.0, "score": 300.0, "intraday_buy_signal": True,
            "intraday_buy_reason": "放量突破", "target_amount": 120000.0, "names_before": 3}
    got = jd._judge_hard_numbers("OPEN", hard)
    assert got["intraday_buy_signal"] is True and got["intraday_buy_reason"] == "放量突破"
    assert "target_amount" not in got and "names_before" not in got, got


@case("回归·既有四类动作的判据与研判范围没被动过")
def _():
    assert ae.JUDGE_ACTIONS == {"FILL", "ADD", "DCA", "OPEN"}, ae.JUDGE_ACTIONS
    assert "TRIM" not in ae.JUDGE_ACTIONS, "减持永远不送研判"
    assert [a for a, _ in ae.EVALUATORS] == ["TRIM", "ADD", "FILL", "DCA"]
    # 持仓那条扫描的输入与产出一个字没改: 空持仓进去, 空候选出来, 不会去碰候选池
    r = ae.scan(positions=[], params=params(), market={})
    assert r == {"candidates": [], "skipped": []}, r


# ================================================================ 研判请求的硬数字裁剪
@case("研判请求·新建仓只送定性材料, 仓位数字一个不送")
def _():
    hard = {"price": 10.0, "score": 300.0, "theme": "储能", "tier": "强传导",
            "upside": 0.3, "heat": 0.5, "plan_rank": 1, "plan_bucket": "main",
            "plan_src": "plan_api", "sector": "电力设备",
            # 下面这些是仓位口径, 决策系统本来就不管仓位
            "target_pct": 0.06, "target_amount": 120000.0, "base_amount": 60000.0,
            "batch_scheme": "0.5,0.25,0.25", "names_before": 3, "max_names": 20,
            "room_amt_before": 1_400_000.0}
    got = jd._judge_hard_numbers("OPEN", hard)
    assert set(got) <= set(jd.OPEN_JUDGE_KEYS), got
    assert set(got) == {k for k in hard if k in jd.OPEN_JUDGE_KEYS}, got
    for k in ("target_amount", "names_before", "max_names", "room_amt_before",
              "batch_scheme", "base_amount", "target_pct"):
        assert k not in got, f"{k} 不该送到决策系统"
    assert got["score"] == 300.0 and got["price"] == 10.0, got


@case("研判请求·加仓类原样送 (它们的硬数字本身就是判据)")
def _():
    hard = {"cushion_pct": -0.085, "stage": 1, "base_qty": 1000}
    for act in ("FILL", "ADD", "DCA", "SWITCH"):
        assert jd._judge_hard_numbers(act, hard) == hard, act
    assert jd._judge_hard_numbers("DCA", None) == {}


@case("研判请求·数据库类型不许把请求体炸掉 (Decimal / 日期)")
def _():
    import datetime as _dt
    from decimal import Decimal
    payload = {
        "qty": 600,
        "hard_numbers": {"price": Decimal("34.75"), "score": 268.5},
        "context": {
            # 这就是 2026-08-06 的现场: 评审账本流水里的 price 取自 DECIMAL 列
            "recent_ledger": [{"at": "2026-08-06 14:08:35", "price": Decimal("10.02"),
                               "verdict": "REJECT"}],
            "position": {"opened_date": _dt.date(2026, 8, 4),
                         "updated_at": _dt.datetime(2026, 8, 6, 14, 8, 35)},
        },
    }
    got = jd.jsonable(payload)
    assert got["hard_numbers"]["price"] == 34.75
    assert isinstance(got["hard_numbers"]["price"], float)
    assert isinstance(got["context"]["recent_ledger"][0]["price"], float)
    assert got["context"]["position"]["opened_date"] == "2026-08-04"
    assert got["context"]["position"]["updated_at"].startswith("2026-08-06 14:08:35")
    # 换完之后必须真的能序列化 —— 这一条才是这个用例的重点
    import json as _json
    _json.dumps(got, ensure_ascii=False)
    # 其余类型原样不动
    assert jd.jsonable({"a": 1, "b": "x", "c": None, "d": True}) == \
        {"a": 1, "b": "x", "c": None, "d": True}


@case("研判请求·账本流水里的价格就地转成 float (不靠出口那一道兜)")
def _():
    import inspect
    from app.services import proposal_service as prs
    src = inspect.getsource(prs._recent_ledger)
    assert 'float(r.get("price_at") or 0)' in src, \
        "_recent_ledger 必须把 price_at 转成 float, 否则日志与留痕里带的是 Decimal"


# ================================================================ 参考位漂移
@case("参考位漂移·只管新建仓, 别的动作一律不比对")
def _():
    with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}):
        prog = {"ref_lock": {"ymd": 20260806, "support": 31.27, "pressure": 40.0}}
        adv = {"support": 29.00, "pressure": 40.0}
        assert ea._check_ref_drift(action="FILL", prog=prog, adv=adv, today=20260806) == ""
        assert ea._check_ref_drift(action="ADD", prog=prog, adv=adv, today=20260806) == ""
        assert ea._check_ref_drift(action=None, prog=prog, adv=adv, today=20260806) == ""


@case("参考位漂移·当日首答只锁定不比对")
def _():
    with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}):
        prog = {}
        adv = {"support": 31.27, "pressure": 40.0, "buy_band": [30.96, 33.89],
               "consulted_at": "09:35"}
        assert ea._check_ref_drift(action="OPEN", prog=prog, adv=adv, today=20260806) == ""
        assert prog["ref_lock"]["ymd"] == 20260806
        assert prog["ref_lock"]["support"] == 31.27
        assert prog["ref_lock"]["at"] == "09:35"


@case("参考位漂移·没漂过阈值就放行")
def _():
    with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}):
        prog = {"ref_lock": {"ymd": 20260806, "support": 31.27, "pressure": 40.0}}
        adv = {"support": 31.50, "pressure": 40.0}       # 差 0.7%
        assert ea._check_ref_drift(action="OPEN", prog=prog, adv=adv, today=20260806) == ""


@case("参考位漂移·漂过阈值就报出来 (002335 实证: 支撑 31.27→29.00)")
def _():
    with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}):
        prog = {"ref_lock": {"ymd": 20260806, "support": 31.27, "pressure": 40.0,
                             "at": "09:35"}}
        adv = {"support": 29.00, "pressure": 40.0}
        why = ea._check_ref_drift(action="OPEN", prog=prog, adv=adv, today=20260806)
        assert why, "漂了 7.3% 必须报出来"
        assert "31.27" in why and "29.0" in why, why
        assert "当日暂停" in why, why


@case("参考位漂移·压力位单独漂也算 (000035 实证: 压力 5.2→5.15 未过阈值)")
def _():
    with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}):
        prog = {"ref_lock": {"ymd": 20260806, "support": 5.0, "pressure": 5.2}}
        assert ea._check_ref_drift(action="OPEN", prog=prog,
                                   adv={"support": 5.0, "pressure": 5.15},
                                   today=20260806) == ""       # 差 0.96%, 不到阈值
        why = ea._check_ref_drift(action="OPEN", prog=prog,
                                  adv={"support": 5.0, "pressure": 4.5},
                                  today=20260806)              # 差 13.5%
        assert "压力" in why, why


@case("参考位漂移·锁按日重置, 次日的新结论不算漂移")
def _():
    with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}):
        prog = {"ref_lock": {"ymd": 20260806, "support": 31.27, "pressure": 40.0}}
        adv = {"support": 29.00, "pressure": 40.0, "consulted_at": "09:31"}
        assert ea._check_ref_drift(action="OPEN", prog=prog, adv=adv, today=20260807) == ""
        assert prog["ref_lock"]["ymd"] == 20260807, "次日首答应该重新锁定"
        assert prog["ref_lock"]["support"] == 29.00


@case("参考位漂移·阈值设成 0 等于关掉这道防护")
def _():
    with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.0}):
        prog = {"ref_lock": {"ymd": 20260806, "support": 31.27, "pressure": 40.0}}
        adv = {"support": 1.0, "pressure": 40.0}
        assert ea._check_ref_drift(action="OPEN", prog=prog, adv=adv, today=20260806) == ""


@case("参考位漂移·拿不到支撑压力时不锁也不误报")
def _():
    with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}):
        prog = {}
        assert ea._check_ref_drift(action="OPEN", prog=prog,
                                   adv={"support": None, "pressure": None},
                                   today=20260806) == ""
        assert "ref_lock" not in prog, "没有位就不该锁一份空的进去"


# ================================================================ 窗口末日的强制完成
@case("窗口末日·命令驱动照旧强制完成 (用户下过命令, 到期必须完成)")
def _():
    from app.core import exec_timing as et
    prm = {"buy_halt_dayup": 0.05, "eod_force_time": "14:45", "eod_force_discount": 0.998}
    d = {"price": 10.0, "day_chg_from_open": 0.01, "bars": 60}
    r = et.hard_gate(side="buy", now="14:46", day=d, params=prm,
                     is_last_day=True, is_command=True, quota=1000)
    assert r["action"] == et.ACT_FIRE and r["forced"] is True, r
    assert r["limit_price"] == 10.02, r          # 10 × 1.002


@case("窗口末日·自主买入不强制追买, 到期作废 (与 window_verdict 的口径对齐)")
def _():
    from app.core import exec_timing as et
    prm = {"buy_halt_dayup": 0.05, "eod_force_time": "14:45", "eod_force_discount": 0.998}
    d = {"price": 10.0, "day_chg_from_open": 0.01, "bars": 60}
    r = et.hard_gate(side="buy", now="14:46", day=d, params=prm,
                     is_last_day=True, is_command=False, quota=1000)
    assert r["action"] == et.ACT_WAIT and not r["forced"], r
    assert "不追买" in r["reason"] and "到期作废" in r["reason"], r["reason"]
    # 与窗口收口那侧说的是同一件事: 自主类到期作废
    assert et.window_verdict(remaining_qty=500, tdays_left=0,
                             is_command=False)["verdict"] == "EXPIRED"


@case("窗口末日·非末日两侧行为一致 (都是顺延次日)")
def _():
    from app.core import exec_timing as et
    prm = {"buy_halt_dayup": 0.05, "eod_force_time": "14:45", "eod_force_discount": 0.998}
    d = {"price": 10.0, "day_chg_from_open": 0.01, "bars": 60}
    for is_cmd in (True, False):
        r = et.hard_gate(side="buy", now="14:46", day=d, params=prm,
                         is_last_day=False, is_command=is_cmd, quota=1000)
        assert r["action"] == et.ACT_WAIT and "顺延次日" in r["reason"], (is_cmd, r)


@case("窗口末日·卖出侧一个字没改: 两种来源都照常兜底 (宁可买不上, 不能卖不掉)")
def _():
    from app.core import exec_timing as et
    prm = {"sell_avoid_open_min": 30, "eod_force_time": "14:45", "eod_force_discount": 0.998}
    d = {"price": 10.0, "day_chg_from_open": -0.01, "bars": 60}
    for is_cmd in (True, False):
        r = et.hard_gate(side="sell", now="14:46", day=d, params=prm,
                         is_last_day=True, is_command=is_cmd, quota=1000)
        assert r["action"] == et.ACT_FIRE and r["forced"] is True, (is_cmd, r)
        assert r["limit_price"] == 9.98, r       # 10 × 0.998


@case("窗口末日·hard_gate 的 is_command 是必填的 (漏传立刻炸, 不许静默按某一侧走)")
def _():
    from app.core import exec_timing as et
    try:
        et.hard_gate(side="buy", now="14:46", day={"price": 10.0}, params={},
                     is_last_day=True, quota=1000)
    except TypeError:
        pass
    else:
        raise AssertionError("漏传 is_command 必须抛 TypeError")


@case("窗口末日·executor 真的把 is_command 传下去了 (签名对得上)")
def _():
    import inspect
    from app.services import exec_advisor as ea
    from app.core import exec_timing as et
    assert "is_command" in inspect.signature(ea.decide).parameters
    assert "is_command" in inspect.signature(et.hard_gate).parameters
    assert "is_command" in inspect.signature(et.decide).parameters
    src = inspect.getsource(__import__("app.services.executor", fromlist=["x"]).run_tick)
    assert "is_command=bool(prog.get(\"is_command\"))" in src, \
        "executor.run_tick 必须显式把指令的 is_command 传给择时, 否则这道分岔形同虚设"


# ================================================================ 研判的单轮时间预算
@case("研判预算·没设预算就一律放行")
def _():
    from app.services import proposal_service as prs
    assert prs._judge_budget_left(None) is True


@case("研判预算·够一次就放行, 不够就留到下一跳")
def _():
    import time
    from app.services import proposal_service as prs
    with _PatchParams({"PMS_JUDGE_TIMEOUT": 90}):
        assert prs._judge_budget_left(time.monotonic() + 150) is True   # 150 > 90
        assert prs._judge_budget_left(time.monotonic() + 30) is False   # 30 < 90
        assert prs._judge_budget_left(time.monotonic() - 1) is False    # 已经超了


# ================================================================ 跑
def main():
    passed, failed = 0, []
    for name, fn in RESULTS:
        try:
            fn()
            passed += 1
            print(f"  ✓ {name}")
        except Exception as e:
            failed.append((name, e))
            print(f"  ✗ {name}: {type(e).__name__}: {e}")
            traceback.print_exc()
    print()
    if failed:
        print(f"FAILED {len(failed)}/{len(RESULTS)}")
        sys.exit(1)
    print(f"ALL PASS ({passed} cases)")


if __name__ == "__main__":
    main()
