# -*- coding: utf-8 -*-
"""
第三批模块单测 (实机运行, 零外部依赖)
======================================
运行: 在 tradingSystem 仓库根目录执行  python scripts/test_batch3_units.py
覆盖: exec_timing 分日配额/分笔/买卖出手判定/兜底/顺延/窗口收口;
      rule_gate 减持放行口径、增持全约束、命令与自主的刹车差别。
约定同前: 全过输出 "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 exec_timing as et                                         # noqa: E402
from app.core import rule_gate as rg                                           # noqa: E402

RESULTS = []


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


PRM = {"sell_avoid_open_min": 30, "buy_halt_dayup": 0.05,
       "eod_force_time": "14:45", "eod_force_discount": 0.998}


def day(**kw):
    d = {"price": 10.0, "vwap": 10.0, "high": 10.5, "low": 9.5, "open": 10.0,
         "day_chg_from_open": 0.0, "bars": 60}
    d.update(kw)
    return d


# ================================================================ exec_timing
@case("分日配额·整除/上取整到一手/最后一日全出/零股尾巴并入")
def _():
    assert et.daily_quota(6000, 3) == 2000
    assert et.daily_quota(5000, 3) == 1700          # 1666.7 → 上取整到一手
    assert et.daily_quota(1000, 1) == 1000          # 最后一日全出
    assert et.daily_quota(150, 3) == 150            # 尾巴不足一手 → 一次出完
    assert et.daily_quota(0, 3) == 0
    assert et.daily_quota(100, 5) == 100
    # 整票清仓允许零股
    assert et.daily_quota(14050, 3, allow_odd_tail=True) == 4700
    assert et.daily_quota(14050, 1, allow_odd_tail=True) == 14050


@case("分笔·配额切成 N 笔, 余数并入最后一笔")
def _():
    assert et.slice_qty(3000, 3) == [1000, 1000, 1000]
    assert et.slice_qty(1000, 3) == [300, 300, 400]
    assert et.slice_qty(100, 3) == [100]
    assert et.slice_qty(0, 3) == []
    assert et.slice_qty(2500, 1) == [2500]


@case("卖出择时·避开开盘30分钟 → 站上均价才出手 → 限价打折")
def _():
    r = et.decide(side="sell", now="09:45", day=day(), params=PRM, is_last_day=False, quota=2000)
    assert r["action"] == et.ACT_WAIT and "避开开盘" in r["reason"], r
    r = et.decide(side="sell", now="10:05", day=day(price=10.2, vwap=10.0), params=PRM,
                  is_last_day=False, quota=2000)
    assert r["action"] == et.ACT_FIRE and r["limit_price"] == 10.18, r   # 10.2×0.998
    r = et.decide(side="sell", now="10:05", day=day(price=9.8, vwap=10.0), params=PRM,
                  is_last_day=False, quota=2000)
    assert r["action"] == et.ACT_WAIT and "等更好的价" in r["reason"], r


@case("卖出择时·14:45 兜底强制出手 (不管均价)")
def _():
    r = et.decide(side="sell", now="14:45", day=day(price=9.5, vwap=10.0), params=PRM,
                  is_last_day=False, quota=2000)
    assert r["action"] == et.ACT_FIRE and r["forced"] is True, r
    assert r["limit_price"] == 9.48, r                                   # 9.5×0.998
    assert et.decide(side="sell", now="14:44", day=day(price=9.5, vwap=10.0), params=PRM,
                     is_last_day=False, quota=2000)["action"] == et.ACT_WAIT


@case("买入择时·跌破均价才买 / 回踩带买 / 不追高停手")
def _():
    r = et.decide(side="buy", now="10:05", day=day(price=9.8, vwap=10.0), params=PRM,
                  is_last_day=False, quota=3000)
    assert r["action"] == et.ACT_FIRE and r["limit_price"] == 9.82, r    # 9.8×1.002
    r = et.decide(side="buy", now="10:05", day=day(price=10.3, vwap=10.0), params=PRM,
                  is_last_day=False, quota=3000)
    assert r["action"] == et.ACT_WAIT and "等回调" in r["reason"], r
    r = et.decide(side="buy", now="10:05", day=day(price=10.3, vwap=10.0, support=10.2),
                  params=PRM, is_last_day=False, quota=3000)
    assert r["action"] == et.ACT_FIRE and "回踩带" in r["reason"], r
    r = et.decide(side="buy", now="10:05", day=day(price=11.0, vwap=10.0,
                                                   day_chg_from_open=0.06),
                  params=PRM, is_last_day=False, quota=3000)
    assert r["action"] == et.ACT_STOP and "不追高" in r["reason"], r


@case("买入择时·窗口末日 14:45 强制完成, 非末日则顺延")
def _():
    r = et.decide(side="buy", now="14:50", day=day(price=10.5, vwap=10.0), params=PRM,
                  is_last_day=True, quota=3000)
    assert r["action"] == et.ACT_FIRE and r["forced"] and r["limit_price"] == 10.52, r
    r = et.decide(side="buy", now="14:50", day=day(price=10.5, vwap=10.0), params=PRM,
                  is_last_day=False, quota=3000)
    assert r["action"] == et.ACT_WAIT and "顺延次日" in r["reason"], r


@case("择时·停牌/一字板/非交易时段/配额出完 一律不动")
def _():
    assert et.decide(side="sell", now="10:05", day=day(price=0), params=PRM,
                     is_last_day=False, quota=100)["action"] == et.ACT_SKIP
    assert et.decide(side="sell", now="10:05", day=day(halted=True), params=PRM,
                     is_last_day=False, quota=100)["action"] == et.ACT_SKIP
    assert et.decide(side="buy", now="10:05", day=day(limit_up=True), params=PRM,
                     is_last_day=False, quota=100)["action"] == et.ACT_SKIP
    r = et.decide(side="sell", now="10:05", day=day(limit_down=True), params=PRM,
                  is_last_day=False, quota=100)
    assert r["action"] == et.ACT_SKIP and "顺延" in r["reason"], r
    assert et.decide(side="sell", now="12:00", day=day(), params=PRM,
                     is_last_day=False, quota=100)["action"] == et.ACT_WAIT
    r = et.decide(side="sell", now="10:05", day=day(price=10.2), params=PRM,
                  is_last_day=False, quota=1000, fired_today=1000)
    assert r["action"] == et.ACT_WAIT and "配额已出完" in r["reason"], r


@case("择时·时点解析与交易时段判定")
def _():
    assert et.hm_to_min("14:45") == 885 and et.hm_to_min((9, 30)) == 570
    assert et.in_session(570) and et.in_session(690) and et.in_session(780)
    assert not et.in_session(569) and not et.in_session(700) and not et.in_session(901)


@case("窗口收口·命令类置部分完成, 自主类作废, 未到期继续")
def _():
    assert et.window_verdict(remaining_qty=0, tdays_left=0, is_command=True)["verdict"] == "DONE"
    assert et.window_verdict(remaining_qty=500, tdays_left=2,
                             is_command=True)["verdict"] == "RUNNING"
    v = et.window_verdict(remaining_qty=500, tdays_left=0, is_command=True)
    assert v["verdict"] == "PARTIAL" and "告警" in v["note"], v
    assert et.window_verdict(remaining_qty=500, tdays_left=0,
                             is_command=False)["verdict"] == "EXPIRED"


@case("窗口收口·自主卖出不作废 (2026-08-17: 宁可买不上, 不能卖不掉)")
def _():
    # 信号清仓/保垫减仓窗口耗尽 → 保持在途按末日节奏继续, 不作废
    v = et.window_verdict(remaining_qty=500, tdays_left=0, is_command=False, side="sell")
    assert v["verdict"] == "PARTIAL" and "不作废" in v["note"], v
    # 自主买入照旧作废; 命令类两个方向都置部分完成 (口径不变)
    assert et.window_verdict(remaining_qty=500, tdays_left=0,
                             is_command=False, side="buy")["verdict"] == "EXPIRED"
    assert et.window_verdict(remaining_qty=500, tdays_left=0,
                             is_command=True, side="sell")["verdict"] == "PARTIAL"
    # side 不传 = 旧口径 (兼容既有调用)
    assert et.window_verdict(remaining_qty=500, tdays_left=0,
                             is_command=False)["verdict"] == "EXPIRED"


@case("紧急卖出直通·不避开盘不等均价, 限价更激进, 兜底时点后转 forced")
def _():
    prm = {**PRM, "urgent_sell_discount": 0.995}
    # 09:35 开盘半小时内 + 现价低于均价 —— 普通卖出会 WAIT, 紧急直接 FIRE
    r = et.decide(side="sell", now="09:35", day=day(price=9.8, vwap=10.0), params=prm,
                  is_last_day=False, quota=2000, urgent=True)
    assert r["action"] == et.ACT_FIRE and r["forced"] is False, r
    assert r["limit_price"] == 9.75, r                    # 9.8×0.995=9.751 → round 9.75
    assert "紧急" in r["reason"], r
    # 14:50 (兜底时点后) 紧急单转 forced —— 挂到收盘不被 TTL 撤回
    r = et.decide(side="sell", now="14:50", day=day(price=9.8, vwap=10.0), params=prm,
                  is_last_day=False, quota=2000, urgent=True)
    assert r["action"] == et.ACT_FIRE and r["forced"] is True, r
    # 跌停一字板照旧顺延 (紧急也卖不出去, 别把单堆在封单后面空耗)
    r = et.decide(side="sell", now="10:30",
                  day=day(price=9.0, vwap=10.0, high=9.0, low=9.0,
                          day_chg_from_open=-0.1, limit_down=True),
                  params=prm, is_last_day=False, quota=2000, urgent=True)
    assert r["action"] == et.ACT_SKIP, r
    # 配额出完照旧等 (紧急不越过配额纪律)
    r = et.decide(side="sell", now="10:30", day=day(price=9.8, vwap=10.0), params=prm,
                  is_last_day=False, quota=2000, fired_today=2000, urgent=True)
    assert r["action"] == et.ACT_WAIT, r


@case("分桶收口·桶时点解析与累计应出量补齐")
def _():
    assert et.parse_bucket_times("11:30,14:00") == [690, 840]
    assert et.parse_bucket_times(" 14:00 , 11:30 ,") == [690, 840]
    assert et.parse_bucket_times("") == [] and et.parse_bucket_times(None) == []
    assert et.parse_bucket_times("09:30,15:00,abc") == []   # 开收盘边界与坏值都不收
    prm = {**PRM, "sell_bucket_times": "11:30,14:00"}
    # 桶未到 (10:30) 且价低于均价 → 仍是等待 (桶内保留择价条件)
    r = et.decide(side="sell", now="10:30", day=day(price=9.8, vwap=10.0), params=prm,
                  is_last_day=False, quota=3000)
    assert r["action"] == et.ACT_WAIT, r
    # 第一桶 (11:30) 已过、一股没出 → 补齐 3000×1/3=1000 股, 不置 forced
    r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=prm,
                  is_last_day=False, quota=3000, fired_today=0)
    assert r["action"] == et.ACT_FIRE and r["qty_hint"] == 1000, r
    assert r["forced"] is False and "分桶收口" in r["reason"], r
    # 桶内已按择价卖过 1000 → 第一桶份额已够, 继续等更好的价
    r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=prm,
                  is_last_day=False, quota=3000, fired_today=1000)
    assert r["action"] == et.ACT_WAIT, r
    # 第二桶 (14:00) 已过、只出了 1000 → 应出 2000, 补齐 1000
    r = et.decide(side="sell", now="14:05", day=day(price=9.8, vwap=10.0), params=prm,
                  is_last_day=False, quota=3000, fired_today=1000)
    assert r["action"] == et.ACT_FIRE and r["qty_hint"] == 1000, r
    # 差额不足一手 → 留给下一桶或兜底, 不发零股卖单 (规则闸会拦)
    r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=prm,
                  is_last_day=False, quota=250, fired_today=0)
    assert r["action"] == et.ACT_WAIT, r
    # 14:45 兜底优先于分桶 (兜底管收尾, 桶只管中途)
    r = et.decide(side="sell", now="14:45", day=day(price=9.8, vwap=10.0), params=prm,
                  is_last_day=False, quota=3000, fired_today=2000)
    assert r["action"] == et.ACT_FIRE and r["forced"] is True, r
    # 不配桶参数 = 旧行为: 13:05 价低于均价一路等到兜底
    r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=PRM,
                  is_last_day=False, quota=3000, fired_today=0)
    assert r["action"] == et.ACT_WAIT, r
    # 站上均价时照旧全量择价卖出, 不受桶份额限制 (价好就多卖)
    r = et.decide(side="sell", now="13:05", day=day(price=10.2, vwap=10.0), params=prm,
                  is_last_day=False, quota=3000, fired_today=1000)
    assert r["action"] == et.ACT_FIRE and r["qty_hint"] == 2000, r


# ================================================================ rule_gate
def ctx(**kw):
    d = {"ts_code": "600000.SH",
         "position": {"total_qty": 6000, "avail_qty": 6000, "frozen_reason": "NONE"},
         "day": {"price": 10.0, "vwap": 10.0, "ma5": 10.0, "day_chg_from_open": 0.01},
         "params": {"no_chase_ma5": 0.06, "buy_halt_dayup": 0.05},
         "flags": {"buy_halt": False, "exec_halt": False, "brake_active": False,
                   "blacklisted": False, "is_command": False},
         "caps": dict(scale=2_000_000, portfolio_cap=0.60, stock_cap=0.08, max_names=15,
                      portfolio_mv=800_000, names_count=5, stock_mv=60_000, is_new_name=False,
                      sector=None, sector_names=0, sector_mv=0.0, sector_max_names=4,
                      sector_max_ratio=0.40, cash_reserve=0.0, sector_source_ready=True)}
    for k, v in kw.items():
        if isinstance(v, dict) and isinstance(d.get(k), dict):
            d[k] = {**d[k], **v}
        else:
            d[k] = v
    return d


@case("规则闸·卖出正常放行 + 硬数字留痕")
def _():
    r = rg.check(side="sell", action="TRIM", qty=2000, price=10.0, ctx=ctx())
    assert r["passed"] and r["failed"] == [], r
    assert r["hard_numbers"]["avail_qty"] == 6000 and r["hard_numbers"]["qty"] == 2000


@case("规则闸·卖出超持仓/超可卖/零股非清仓 三种拦截")
def _():
    r = rg.check(side="sell", action="EXIT", qty=9000, price=10.0, ctx=ctx())
    assert any(x.startswith("OVER_SELL") for x in r["failed"]), r
    r = rg.check(side="sell", action="TRIM", qty=5000, price=10.0,
                 ctx=ctx(position={"avail_qty": 3000}))
    assert any(x.startswith("T1_UNAVAILABLE") for x in r["failed"]), r
    r = rg.check(side="sell", action="TRIM", qty=1050, price=10.0, ctx=ctx())
    assert any(x.startswith("LOT_INVALID") for x in r["failed"]), r
    # 清仓允许零股
    r = rg.check(side="sell", action="EXIT", qty=6050, price=10.0,
                 ctx=ctx(position={"total_qty": 6050, "avail_qty": 6050}))
    assert r["passed"], r


@case("规则闸·减持不受冻结/刹车/上限影响 (只挡增持)")
def _():
    c = ctx(position={"frozen_reason": "COMMAND_HALT"},
            flags={"brake_active": True, "buy_halt": True})
    r = rg.check(side="sell", action="EXIT", qty=6000, price=10.0, ctx=c)
    assert r["passed"], r
    r2 = rg.check(side="buy", action="ADD", qty=1000, price=10.0, ctx=c)
    assert not r2["passed"] and any(x.startswith("FROZEN") for x in r2["failed"]), r2


@case("规则闸·买入 全局暂停/黑名单/非整百 拦截")
def _():
    r = rg.check(side="buy", action="ADD", qty=1000, price=10.0,
                 ctx=ctx(flags={"buy_halt": True}))
    assert any(x.startswith("BUY_HALT") for x in r["failed"]), r
    r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
                 ctx=ctx(flags={"blacklisted": True}))
    assert any(x.startswith("BLACKLIST") for x in r["failed"]), r
    r = rg.check(side="buy", action="OPEN", qty=150, price=10.0, ctx=ctx())
    assert any(x.startswith("LOT_INVALID") for x in r["failed"]), r


@case("规则闸·昨夜定性形态恶化 → 买入拦截, 卖出不受影响, 定性一律留痕")
def _():
    # 2026-08-05 补这道闸的实机由来: 600841 昨夜定性 SELL、已被候选层请出池子, 而在途的
    # 建仓指令照样在等买入区间 —— 候选层的结论当时对执行层完全不可见。
    for sig in ("SELL", "AVOID", "DROPPED", " sell "):
        r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
                     ctx=ctx(flags={"y_signal": sig}))
        assert any(x.startswith("BAD_SIGNAL") for x in r["failed"]), (sig, r)
        assert r["hard_numbers"]["y_signal"] == sig.strip().upper(), (sig, r)
    # 正常定性不能误伤, 但定性本身照样要写进 hard_numbers (放行的也要查得到)
    ok = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
                  ctx=ctx(flags={"y_signal": "BUY"}))
    assert ok["passed"] and ok["hard_numbers"]["y_signal"] == "BUY", ok
    # 退实现B 时根本拿不到定性: 不拦, 留痕为 None (缺数据不算恶化, 与桥侧同口径)
    nil = rg.check(side="buy", action="OPEN", qty=1000, price=10.0, ctx=ctx())
    assert nil["passed"] and nil["hard_numbers"]["y_signal"] is None, nil
    # 恶化只挡增持, 减持任何时候放行
    sell = rg.check(side="sell", action="EXIT", qty=6000, price=10.0,
                    ctx=ctx(flags={"y_signal": "SELL"}))
    assert sell["passed"], sell


@case("规则闸·刹车对自主是拦截, 对命令只是提示 (命令至上)")
def _():
    auto = rg.check(side="buy", action="ADD", qty=1000, price=10.0,
                    ctx=ctx(flags={"brake_active": True}))
    assert not auto["passed"] and any(x.startswith("BRAKE_ACTIVE") for x in auto["failed"])
    cmd = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
                   ctx=ctx(flags={"brake_active": True, "is_command": True}))
    assert cmd["passed"], cmd
    assert any(w.startswith("BRAKE_ACTIVE") for w in cmd["warnings"]), cmd


@case("规则闸·不追高两道 (当日涨幅 / 距 MA5) 与 MA5 缺失告警")
def _():
    r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
                 ctx=ctx(day={"day_chg_from_open": 0.07}))
    assert any(x.startswith("NO_CHASE_DAYUP") for x in r["failed"]), r
    r = rg.check(side="buy", action="OPEN", qty=1000, price=11.0,
                 ctx=ctx(day={"ma5": 10.0}))
    assert any(x.startswith("NO_CHASE_MA5") for x in r["failed"]), r     # 距 MA5 10% > 6%
    r = rg.check(side="buy", action="OPEN", qty=1000, price=10.3,
                 ctx=ctx(day={"ma5": 10.0}))
    assert r["passed"], r                                                # 3% 在容忍内
    r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0, ctx=ctx(day={"ma5": None}))
    assert r["passed"] and any(w.startswith("MA5_MISSING") for w in r["warnings"]), r


@case("规则闸·买入过组合上限 (复用 planner 同一份口径)")
def _():
    r = rg.check(side="buy", action="ADD", qty=12000, price=10.0, ctx=ctx())
    assert any(x.startswith("STOCK_CAP") for x in r["failed"]), r   # 6万+12万 > 16万
    r = rg.check(side="buy", action="ADD", qty=1000, price=10.0, ctx=ctx(caps=None))
    assert any(x.startswith("CAPS_MISSING") for x in r["failed"]), r


@case("规则闸·资金不足拒买 (真钱基准, 与 scale 上限是两个不同的约束)")
def _():
    # scale 200 万、单股上限 8%=16 万 —— 上限过得了, 但账户真实可用只有 5 万
    cash = {"cash_avail": 50_000.0, "cash_source": "ws"}
    r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0, ctx=ctx(caps=cash))
    assert r["passed"], f"1 万 < 可用 5 万, 该放行: {r}"
    assert r["hard_numbers"]["cash_need"] == 10_000.0
    assert r["hard_numbers"]["cash_source"] == "ws"
    r = rg.check(side="buy", action="OPEN", qty=8000, price=10.0, ctx=ctx(caps=cash))
    assert any(x.startswith("INSUFFICIENT_CASH") for x in r["failed"]), r
    # 卖出不受资金约束 (减持方向不设门槛)
    r = rg.check(side="sell", action="TRIM", qty=2000, price=10.0, ctx=ctx(caps=cash))
    assert r["passed"] and "cash_need" not in r["hard_numbers"], r


@case("规则闸·拿不到 ws 资金快照 → 降级不拦但留痕 (不把通道故障升级成业务停摆)")
def _():
    for caps in ({"cash_source": "estimate", "cash_avail": None},
                 {"cash_source": "estimate", "cash_avail": 1_990_000.0},   # 估算值也不认
                 {}):                                                      # 字段压根没传
        r = rg.check(side="buy", action="OPEN", qty=8000, price=10.0, ctx=ctx(caps=caps))
        assert r["passed"], f"降级不该拦: {caps} → {r}"
        assert any(w.startswith("CASH_ESTIMATED") for w in r["warnings"]), r
        assert not any(x.startswith("INSUFFICIENT_CASH") for x in r["failed"]), r


@case("规则闸·必要输入缺失与全局暂停执行一律拒绝 (宁可不动)")
def _():
    r = rg.check(side="sell", action="EXIT", qty=1000, price=0, ctx=ctx())
    assert any(x.startswith("PRICE_MISSING") for x in r["failed"]), r
    r = rg.check(side="sell", action="EXIT", qty=0, price=10.0, ctx=ctx())
    assert any(x.startswith("QTY_INVALID") for x in r["failed"]), r
    r = rg.check(side="sell", action="EXIT", qty=1000, price=10.0,
                 ctx=ctx(flags={"exec_halt": True}))
    assert any(x.startswith("EXEC_HALT") for x in r["failed"]), r
    r = rg.check(side="sell", action="EXIT", qty=1000, price=10.0,
                 ctx=ctx(day={"halted": True}))
    assert any(x.startswith("HALTED") for x in r["failed"]), r
    r = rg.check(side="hold", action="X", qty=100, price=10.0, ctx=ctx())
    assert any(x.startswith("SIDE_INVALID") for x in r["failed"]), r


@case("规则闸·批量统计 (日报关注区用)")
def _():
    rs = [rg.check(side="sell", action="EXIT", qty=1000, price=10.0, ctx=ctx()),
          rg.check(side="buy", action="OPEN", qty=150, price=10.0, ctx=ctx()),
          rg.check(side="buy", action="OPEN", qty=100, price=10.0,
                   ctx=ctx(flags={"buy_halt": True}))]
    s = rg.summarize(rs)
    assert s["total"] == 3 and s["passed"] == 1 and s["rejected"] == 2, s
    assert s["by_reason"].get("LOT_INVALID") == 1 and s["by_reason"].get("BUY_HALT") == 1, s


@case("择时·挂单有效期: 按交易分钟推进, 跳过午休, 收盘封顶, 兜底单给到收盘")
def _():
    # 普通时段: 10:00 + 10 分钟 = 10:10
    assert et.slice_deadline("10:00", ttl_min=10) == et.hm_to_min("10:10")
    # 跨午休: 11:25 + 10 分钟, 午休 90 分钟不计入 → 用掉上午 5 分钟, 下午再走 5 分钟 = 13:05
    assert et.slice_deadline("11:25", ttl_min=10) == et.hm_to_min("13:05"), \
        et._fmt(et.slice_deadline("11:25", ttl_min=10))
    # 起点正落在午休里 → 从下午开盘起算
    assert et.slice_deadline("12:00", ttl_min=10) == et.hm_to_min("13:10")
    # 尾盘封顶, 不会算出收盘后的时点
    assert et.slice_deadline("14:58", ttl_min=10) == et.hm_to_min("15:00")
    # 兜底单直接给到收盘, 不受 TTL 限制 (今天必须走掉的单不能被 TTL 撤回来)
    assert et.slice_deadline("14:46", ttl_min=10, forced=True) == et.hm_to_min("15:00")
    assert et.slice_deadline("09:35", ttl_min=10, forced=True) == et.hm_to_min("15:00")
    # 边界: 恰好到午休开始不顺延; TTL=0 即当刻失效
    assert et.slice_deadline("11:20", ttl_min=10) == et.hm_to_min("11:30")
    assert et.slice_deadline("10:00", ttl_min=0) == et.hm_to_min("10:00")


# ---------------------------------------------------------------- runner
def main():
    passed, failed = 0, 0
    for name, fn in RESULTS:
        try:
            fn()
            print(f"  PASS  {name}")
            passed += 1
        except Exception:
            print(f"  FAIL  {name}")
            traceback.print_exc()
            failed += 1
    print("-" * 60)
    if failed:
        print(f"FAILED: {failed} / {passed + failed}")
        sys.exit(1)
    print(f"ALL PASS ({passed} cases)")


if __name__ == "__main__":
    main()
