# -*- coding: utf-8 -*-
"""
第十四批模块单测 (宏观择时层, 零外部依赖)
==========================================
运行: 在 tradingSystem 仓库根目录执行  python scripts/test_batch14_units.py
覆盖 (MACRO_TIMING_PLAN.md V3; 参数依据 MACRO_CALIB_2026-08-18.md):
  * 指数计算: 四步公式对照手算值 / 汇率 +1 自然日 as-of 对齐 / 样本不足与停更守卫;
  * 区域判定: 进出迟滞 / 带内横跳不切区 / 不可用与恢复;
  * 周期与对数映射: streak 与峰值推进 / 同深度不重复加码 / 首步保底 / 封顶 / 周期清零;
  * 分方向触发: 偏冷进区即动 / 偏热回落再动按峰值深度 / 直接翻区不动作;
  * 让路: 在途 / 冷却 / 暂停买入 / 刹车 / 地板天花板;
  * 服务层 (打桩): 全自动下命令与冲突让路 / 建议与采纳幂等 / 闸状态读写与陈旧失效 /
    proposal_service 的买入侧过滤留 TRIM。
"""
import json
import math
import os
import sys
import traceback

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

from app.core import macro_rules as mr                                          # noqa: E402

RESULTS = []


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


P = {"hot_th": 25.0, "cold_th": -20.0, "exit_band": 15.0, "confirm_days": 1,
     "trigger_hot": "zone_exit", "trigger_cold": "zone_enter",
     "log_s0": 0.049, "log_k": 1.6, "shift_max": 0.20, "step_min": 0.02,
     "min_pct": 0.20, "max_pct": 0.0, "respect_brake": True,
     "ret_win": 20, "z_win": 40, "beta": 0.02, "stale_tdays": 3}

F0 = {"portfolio_pct": 0.50, "portfolio_cap": 0.60, "buy_halt": False,
      "brake_active": False, "inflight": False,
      "cooldown_left_reduce": 0, "cooldown_left_increase": 0}


# ================================================================ 计算
def _mkdays(n, start=20240102):
    """生成 n 个工作日 ymd (跳过周末, 够单测用)。"""
    from datetime import date, timedelta
    d = date(2024, 1, 2)
    out = []
    while len(out) < n:
        if d.weekday() < 5:
            out.append(int(d.strftime("%Y%m%d")))
        d += timedelta(days=1)
    return out


@case("计算·四步公式对照手算值 (常数汇率利率, 股指恒定日涨)")
def _():
    days = _mkdays(70)
    zs = [(d, 3000.0 * math.exp(0.001 * i)) for i, d in enumerate(days)]
    fx = [(d, 7.2) for d in days]
    sh = [(d, 1.9) for d in days]
    r = mr.compute_hedge_index({"zs": zs, "fx": fx, "shibor": sh}, P, days[-1])
    # 股 20 日收益恒 0.02, 汇 0, 利率变化 0 → spread_adj 恒 0.02 → 标准差为零 → 不可用
    assert not r["ok"] and "标准差" in r["reason"], r


@case("计算·有波动时值与手算 z 一致")
def _():
    days = _mkdays(75)
    zs, px = [], 3000.0
    for i, d in enumerate(days):
        px *= math.exp(0.002 if i % 2 == 0 else -0.001)
        zs.append((d, px))
    fx = [(d, 7.2 + (0.01 if i % 3 == 0 else 0.0)) for i, d in enumerate(days)]
    sh = [(d, 1.9) for d in days]
    r = mr.compute_hedge_index({"zs": zs, "fx": fx, "shibor": sh}, P, days[-1])
    assert r["ok"], r
    # 手算: 复算 spread_adj 序列与末点 z×10
    closes = [v for _, v in zs]
    fx_al, _f = mr.asof_align(days, fx, shift_days=1)
    sh_al, _s = mr.asof_align(days, sh, shift_days=0)
    sr_ = mr.log_rets(closes, 20)
    fr_ = mr.log_rets(fx_al, 20)
    sd_ = mr.diffs(sh_al, 20)
    adj = [None if (sr_[i] is None or fr_[i] is None or sd_[i] is None)
           else sr_[i] + fr_[i] - 0.02 * sd_[i] for i in range(len(days))]
    w = [v for v in adj[-40:] if v is not None]
    m = sum(w) / len(w)
    sd = math.sqrt(sum((x - m) ** 2 for x in w) / (len(w) - 1))
    want = (adj[-1] - m) / sd * 10
    assert abs(r["value"] - round(want, 4)) < 1e-6, (r["value"], want)


@case("计算·汇率 +1 自然日 as-of: 周一用上周五值")
def _():
    tdays = [20260803, 20260804, 20260805]           # 周一到周三
    fx = [(20260731, 7.10), (20260803, 7.20)]         # 周五 / 周一
    vals, filled = mr.asof_align(tdays, fx, shift_days=1)
    assert vals == [7.10, 7.20, 7.20] and filled >= 2, (vals, filled)


@case("计算·样本不足与数据停更都判不可用")
def _():
    days = _mkdays(30)
    zs = [(d, 3000.0 + i) for i, d in enumerate(days)]
    r = mr.compute_hedge_index({"zs": zs, "fx": zs, "shibor": zs}, P, days[-1])
    assert not r["ok"] and "样本不足" in r["reason"], r
    days = _mkdays(70)
    zs = [(d, 3000.0 * math.exp((0.002 if i % 2 else -0.001)))
          for i, d in enumerate(days)]
    # 末日落后今天 30 个自然日 → 停更
    r = mr.compute_hedge_index({"zs": zs, "fx": zs, "shibor": zs}, P,
                               mr.ymd_plus_days(days[-1], 30))
    assert not r["ok"] and "停更" in r["reason"], r


# ================================================================ 区域迟滞
@case("区域·进出迟滞: 26 进热, 20/16 仍在热, 14 才离区")
def _():
    assert mr.zone_next(26, "NEUTRAL", P) == mr.Z_HOT
    assert mr.zone_next(20, mr.Z_HOT, P) == mr.Z_HOT       # 带内延续
    assert mr.zone_next(16, mr.Z_HOT, P) == mr.Z_HOT
    assert mr.zone_next(14, mr.Z_HOT, P) == mr.Z_NEUTRAL   # 穿出退出带
    assert mr.zone_next(24, mr.Z_NEUTRAL, P) == mr.Z_NEUTRAL  # 未过进入阈值不再进


@case("区域·偏冷对称 + 直接翻区 + 不可用")
def _():
    assert mr.zone_next(-21, "NEUTRAL", P) == mr.Z_COLD
    assert mr.zone_next(-16, mr.Z_COLD, P) == mr.Z_COLD
    assert mr.zone_next(-14, mr.Z_COLD, P) == mr.Z_NEUTRAL
    assert mr.zone_next(-21, mr.Z_HOT, P) == mr.Z_COLD     # 单日巨变直接翻
    assert mr.zone_next(None, mr.Z_HOT, P) == mr.Z_UNAVAILABLE
    assert mr.zone_next(26, mr.Z_UNAVAILABLE, P) == mr.Z_HOT   # 恢复后按进入规则重判


# ================================================================ 周期与映射
@case("周期·streak 与峰值推进, 退区带出峰值, 换区清零")
def _():
    c1, ev = mr.carry_cycle("NEUTRAL", None, mr.Z_HOT, 27.0, P, 20260801)
    assert c1["streak"] == 1 and abs(c1["e_peak"] - 2.0) < 1e-9 and ev is None
    c2, ev = mr.carry_cycle(mr.Z_HOT, c1, mr.Z_HOT, 31.0, P, 20260802)
    assert c2["streak"] == 2 and abs(c2["e_peak"] - 6.0) < 1e-9 and ev is None
    c3, ev = mr.carry_cycle(mr.Z_HOT, c2, mr.Z_NEUTRAL, 12.0, P, 20260803)
    assert c3 is None and ev and abs(ev["e_peak"] - 6.0) < 1e-9
    c4, ev = mr.carry_cycle(mr.Z_HOT, c2, mr.Z_COLD, -22.0, P, 20260803)
    assert c4["zone"] == mr.Z_COLD and c4["streak"] == 1 and ev["zone"] == mr.Z_HOT


@case("映射·对数目标: 标定锚点与封顶")
def _():
    # S0=0.049, k=1.6: e=2.9 → ≈5.1%; e=17.1 → ≈12%
    assert abs(mr.log_target(2.9, P) - 0.049 * math.log(1 + 2.9 / 1.6)) < 1e-9
    assert 0.049 < mr.log_target(2.9, P) < 0.053
    assert 0.115 < mr.log_target(17.1, P) < 0.125
    assert mr.log_target(1e9, P) == 0.20                    # 封顶
    assert mr.log_target(0, P) == 0.0 and mr.log_target(-3, P) == 0.0


@case("决策·偏冷进区即动: 浅进区首步保底 2%")
def _():
    cyc = {"zone": mr.Z_COLD, "start": 1, "streak": 1, "e_peak": 0.5, "done_shift": 0.0}
    d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
                  e_now=0.5, flags=dict(F0), params=P)
    assert d["action"] == mr.ACT_INCREASE and abs(d["pct"] - 0.02) < 1e-9, d


@case("决策·偏冷加深才有新步, 同深度不重复加码")
def _():
    # 已执行 5%, 深度对应目标 ≈5% → 无新步
    e = 2.9
    done = mr.log_target(e, P)
    cyc = {"zone": mr.Z_COLD, "streak": 3, "e_peak": e, "done_shift": round(done, 4)}
    d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
                  e_now=e, flags=dict(F0), params=P)
    assert d["action"] is None and not d["blocked"], d
    # 深度翻倍 → 目标增量超过 step_min → 出新步
    d2 = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
                   e_now=10.0, flags=dict(F0), params=P)
    assert d2["action"] == mr.ACT_INCREASE \
        and abs(d2["pct"] - (mr.log_target(10.0, P) - done)) < 1e-4, d2


@case("决策·确认天数: streak 不足不动")
def _():
    p2 = {**P, "confirm_days": 2}
    cyc = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 3.0, "done_shift": 0.0}
    d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
                  e_now=3.0, flags=dict(F0), params=p2)
    assert d["action"] is None and not d["blocked"], d


@case("决策·偏热回落再动: 退区日按峰值深度一次动作, 当日已动过不重复")
def _():
    ev = {"zone": mr.Z_HOT, "e_peak": 6.0, "done_shift": 0.0}
    d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False,
                  e_now=0.0, flags=dict(F0), params=P)
    assert d["action"] == mr.ACT_REDUCE \
        and abs(d["pct"] - mr.log_target(6.0, P)) < 1e-4, d
    d2 = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=True,
                   e_now=0.0, flags=dict(F0), params=P)
    assert d2["action"] is None, d2


@case("决策·偏热在区内 (zone_exit 口径) 不动手; 直接翻冷区旧周期退出不降仓")
def _():
    cyc = {"zone": mr.Z_HOT, "streak": 3, "e_peak": 8.0, "done_shift": 0.0}
    d = mr.decide(zone=mr.Z_HOT, cycle=cyc, exit_event=None, exit_acted=False,
                  e_now=8.0, flags=dict(F0), params=P)
    assert d["action"] is None and not d["blocked"], d
    # HOT 直接翻 COLD: 退出事件不触发降仓 (新区自己的逻辑接管)
    ev = {"zone": mr.Z_HOT, "e_peak": 8.0}
    ccold = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 2.0, "done_shift": 0.0}
    d2 = mr.decide(zone=mr.Z_COLD, cycle=ccold, exit_event=ev, exit_acted=False,
                   e_now=2.0, flags=dict(F0), params=P)
    assert d2["action"] == mr.ACT_INCREASE, d2


@case("决策·偏热 zone_enter 口径 (可切): 进区步进")
def _():
    p2 = {**P, "trigger_hot": "zone_enter"}
    cyc = {"zone": mr.Z_HOT, "streak": 1, "e_peak": 3.0, "done_shift": 0.0}
    d = mr.decide(zone=mr.Z_HOT, cycle=cyc, exit_event=None, exit_acted=False,
                  e_now=3.0, flags=dict(F0), params=p2)
    assert d["action"] == mr.ACT_REDUCE and d["pct"] > 0, d


@case("决策·地板与天花板裁剪")
def _():
    ev = {"zone": mr.Z_HOT, "e_peak": 20.0}
    d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False,
                  e_now=0.0, flags={**F0, "portfolio_pct": 0.21}, params=P)
    assert d["action"] is None and any("地板" in b for b in d["blocked"]), d
    cyc = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 9.0, "done_shift": 0.0}
    d2 = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
                   e_now=9.0, flags={**F0, "portfolio_pct": 0.595}, params=P)
    assert d2["action"] is None and any("天花板" in b for b in d2["blocked"]), d2
    # 天花板裁剪但仍够一步
    d3 = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
                   e_now=9.0, flags={**F0, "portfolio_pct": 0.55}, params=P)
    assert d3["action"] == mr.ACT_INCREASE and abs(d3["pct"] - 0.05) < 1e-9, d3


@case("决策·让路: 暂停买入/刹车只拦升仓, 在途与冷却双向拦")
def _():
    cyc = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 5.0, "done_shift": 0.0}
    d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
                  e_now=5.0, flags={**F0, "buy_halt": True}, params=P)
    assert d["action"] is None and any("暂停买入" in b for b in d["blocked"]), d
    d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
                  e_now=5.0, flags={**F0, "brake_active": True}, params=P)
    assert d["action"] is None and any("刹车" in b for b in d["blocked"]), d
    d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
                  e_now=5.0, flags={**F0, "brake_active": True},
                  params={**P, "respect_brake": False})
    assert d["action"] == mr.ACT_INCREASE, d             # 参数放开则不让
    ev = {"zone": mr.Z_HOT, "e_peak": 6.0}
    d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False,
                  e_now=0.0, flags={**F0, "buy_halt": True, "brake_active": True},
                  params=P)
    assert d["action"] == mr.ACT_REDUCE, d               # 降仓不受这两条限制
    d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False,
                  e_now=0.0, flags={**F0, "inflight": True}, params=P)
    assert d["action"] is None and any("在途" in b for b in d["blocked"]), d
    d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False,
                  e_now=0.0, flags={**F0, "cooldown_left_reduce": 2}, params=P)
    assert d["action"] is None and any("冷却" in b for b in d["blocked"]), d


# ================================================================ 服务层 (打桩)
from app.services import macro_service as ms                                    # noqa: E402
from app.services import param_store, proposal_service                          # noqa: E402
from app.repo import macro_repo, pms_repo                                       # noqa: E402


class _Env:
    """打桩环境: 参数 / 信号表 / 命令服务 / 组合快照 全部进内存。"""

    def __init__(self, **params):
        self.params = {"PMS_MACRO_ENABLED": True, "PMS_MACRO_AUTONOMY": "full",
                       "PMS_MACRO_STOCK_GATE": True, "PMS_GLOBAL_EXEC_HALT": False,
                       "PMS_GLOBAL_BUY_HALT": False, "PMS_BRAKE_UNTIL": 0,
                       "PMS_MACRO_COLD_TH": -20.0, **params}
        self.rows = {}                 # (key, ymd) -> row
        self.issued = []
        self.ledger = []
        self.commands = {}             # command_id -> {status,...}
        self.issue_result = {"ok": True, "command_id": "CMD_T_0001"}
        self.saved = {}

    def install(self):
        env = self
        self._orig = (param_store.get, param_store.get_bool, param_store.get_float,
                      param_store.get_int, param_store.get_list, param_store.set_param,
                      macro_repo.fetch_hedge_inputs, macro_repo.upsert_signal,
                      macro_repo.get_signal, macro_repo.recent_signals,
                      pms_repo.insert_ledger, pms_repo.get_command)

        def _get(key, default=None):
            return env.params.get(key, ms._params.__defaults__ or default) \
                if key in env.params else default
        param_store.get = lambda k, d=None: env.params.get(k, d)
        param_store.get_bool = lambda k, d=False: bool(env.params.get(k, d))
        param_store.get_float = lambda k, d=0.0: float(env.params.get(k, d))
        param_store.get_int = lambda k, d=0: int(env.params.get(k, d))
        param_store.get_list = lambda k, d=None, sep=",": \
            [x.strip() for x in str(env.params.get(k, "") or "").split(sep) if x.strip()] \
            or list(d or [])
        def _setp(k, v, by="user"):
            env.saved[k] = v
            env.params[k] = v
            return {"ok": True, "key": k, "value": v}
        param_store.set_param = _setp

        macro_repo.fetch_hedge_inputs = lambda limit_days=140: env.inputs
        def _ups(**kw):
            env.rows[(kw["signal_key"], int(kw["trade_date"]))] = {
                "signal_key": kw["signal_key"], "trade_date": int(kw["trade_date"]),
                "value": kw.get("value"), "zone": kw.get("zone"),
                "detail": kw.get("detail") or {}, "action": kw.get("action") or "NONE",
                "ref_id": kw.get("ref_id") or "", "note": kw.get("note") or ""}
            return 1
        macro_repo.upsert_signal = _ups
        macro_repo.get_signal = lambda k, d: env.rows.get((k, int(d)))
        macro_repo.recent_signals = lambda k, limit=30: sorted(
            [r for (kk, _), r in env.rows.items() if kk == k],
            key=lambda r: -r["trade_date"])[:limit]
        pms_repo.insert_ledger = lambda **kw: env.ledger.append(kw) or 1
        pms_repo.get_command = lambda cid: env.commands.get(cid)

        import app.services.command_service as csvc
        self._orig_issue = csvc.issue
        def _issue(cmd_type, params, **kw):
            env.issued.append({"cmd_type": cmd_type, "params": params, **kw})
            r = dict(env.issue_result)
            if r.get("ok"):
                env.commands[r["command_id"]] = {"command_id": r["command_id"],
                                                 "cmd_type": cmd_type,
                                                 "status": "EXECUTING", "progress": {}}
            return r
        csvc.issue = _issue

        import app.services.portfolio as pf
        self._orig_view = pf.positions_view
        pf.positions_view = lambda **kw: {"totals": {"portfolio_pct": 0.50},
                                          "params": {"portfolio_cap": 0.60}}
        ms._gate_cache["at"] = 0.0
        ms._gate_cache["data"] = None

    def uninstall(self):
        (param_store.get, param_store.get_bool, param_store.get_float,
         param_store.get_int, param_store.get_list, param_store.set_param,
         macro_repo.fetch_hedge_inputs, macro_repo.upsert_signal,
         macro_repo.get_signal, macro_repo.recent_signals,
         pms_repo.insert_ledger, pms_repo.get_command) = self._orig
        import app.services.command_service as csvc
        csvc.issue = self._orig_issue
        import app.services.portfolio as pf
        pf.positions_view = self._orig_view
        ms._gate_cache["at"] = 0.0
        ms._gate_cache["data"] = None


def _cold_inputs():
    """构造一组会算出偏冷极值的序列: 末 20 日股指大跌 + 人民币升值。"""
    days = _mkdays(80)
    zs, px = [], 3300.0
    fxs, fxv = [], 7.30
    for i, d in enumerate(days):
        drift = 0.001 if i % 2 == 0 else -0.0005
        if i >= len(days) - 15:
            drift = -0.012                       # 尾段连续大跌
        px *= math.exp(drift)
        zs.append((d, px))
        fdrift = 0.0002 if i % 3 == 0 else 0.0
        if i >= len(days) - 15:
            fdrift = -0.004                      # 人民币升值 (USDCNH 下行)
        fxv *= math.exp(fdrift)
        fxs.append((d, fxv))
    sh = [(d, 1.9 + (0.01 if i % 5 == 0 else 0.0)) for i, d in enumerate(days)]
    return {"zs": zs, "fx": fxs, "shibor": sh}, days[-1]


@case("服务·全自动: 偏冷极值 → 下升仓命令 (署名 macro), 信号行与闸状态落表")
def _():
    env = _Env(PMS_MACRO_SIGNALS="hedge_fx")
    env.inputs, today = _cold_inputs()
    env.install()
    try:
        r = ms.scan(now=__import__("datetime").datetime.strptime(str(today), "%Y%m%d"))
        assert r["ok"], r
        assert env.issued and env.issued[0]["cmd_type"] == "INCREASE_EXPOSURE", env.issued
        assert env.issued[0]["issued_by"] == "macro"
        assert 0.02 <= env.issued[0]["params"]["pct"] <= 0.20
        row = env.rows[("hedge_fx", today)]
        assert row["action"] == "CMD_ISSUED" and row["ref_id"] == "CMD_T_0001", row
        assert row["zone"] == mr.Z_COLD and float(row["value"]) < -20
        # 周期已执行量被推进 (进区口径)
        assert row["detail"]["cycle"]["done_shift"] > 0, row["detail"]
        # 偏冷不落闸
        st = json.loads(env.saved["PMS_MACRO_GATE_STATE"])
        assert st["active"] is False
        # 评审账本有 PASS 留痕
        assert any(x.get("verdict") == "PASS" for x in env.ledger)
    finally:
        env.uninstall()


@case("服务·同日重扫不重复下命令; 冲突时让路留痕")
def _():
    env = _Env()
    env.inputs, today = _cold_inputs()
    env.install()
    try:
        now = __import__("datetime").datetime.strptime(str(today), "%Y%m%d")
        ms.scan(now=now)
        n1 = len(env.issued)
        ms.scan(now=now)                          # 重扫: 当日已下过 → 不再下
        assert len(env.issued) == n1 == 1, env.issued
        # 冲突场景: 清空当日行, 命令服务回 CONFLICT
        env.rows.clear()
        env.issued.clear()
        env.ledger.clear()
        env.issue_result = {"ok": False, "errors": ["CONFLICT: 与在途命令冲突"],
                            "conflicts": [{"with_cmd_type": "REDUCE_EXPOSURE"}]}
        r = ms.scan(now=now)
        row = env.rows[("hedge_fx", today)]
        assert row["action"] == "BLOCKED" and "CONFLICT" in row["note"], row
        assert any(x.get("verdict") == "REJECT" for x in env.ledger)
        assert r["skipped"], r
    finally:
        env.uninstall()


@case("服务·只出建议档: 建议落行, 采纳幂等, 二次采纳被拒")
def _():
    env = _Env(PMS_MACRO_AUTONOMY="propose_only")
    env.inputs, today = _cold_inputs()
    env.install()
    try:
        now = __import__("datetime").datetime.strptime(str(today), "%Y%m%d")
        ms.scan(now=now)
        assert not env.issued
        row = env.rows[("hedge_fx", today)]
        assert row["action"] == "ADVICE_INCREASE" and row["detail"]["advice"]["pct"] > 0
        # 采纳 (td.ymd() 是真实今天, 与合成日期不同 → 先把行挪到真实今天再采纳)
        from app.core import tradedays as td
        real_today = td.ymd()
        env.rows[("hedge_fx", real_today)] = {**row, "trade_date": real_today}
        r = ms.adopt("hedge_fx")
        assert r.get("ok") and env.issued and env.issued[0]["issued_by"] == "user", r
        r2 = ms.adopt("hedge_fx")
        assert not r2.get("ok") and "不重复" in r2.get("error", ""), r2
    finally:
        env.uninstall()


@case("服务·偏热日落闸: 闸状态写入, gate_state 读回生效; 陈旧与关档失效")
def _():
    env = _Env()
    days = _mkdays(80)
    # 构造偏热: 尾段股指大涨 + 人民币贬值
    zs, px = [], 3000.0
    fxs, fxv = [], 6.90
    for i, d in enumerate(days):
        drift = 0.0008 if i % 2 == 0 else -0.0006
        if i >= len(days) - 12:
            drift = 0.012
        px *= math.exp(drift)
        zs.append((d, px))
        fdrift = -0.0002 if i % 3 == 0 else 0.0
        if i >= len(days) - 12:
            fdrift = 0.004
        fxv *= math.exp(fdrift)
        fxs.append((d, fxv))
    env.inputs = {"zs": zs, "fx": fxs,
                  "shibor": [(d, 1.9 + (0.01 if i % 5 == 0 else 0.0))
                             for i, d in enumerate(days)]}
    env.install()
    try:
        now = __import__("datetime").datetime.strptime(str(days[-1]), "%Y%m%d")
        r = ms.scan(now=now)
        row = env.rows[("hedge_fx", days[-1])]
        assert row["zone"] == mr.Z_HOT, row
        # zone_exit 口径: 在区内不下命令
        assert not env.issued
        st = json.loads(env.saved["PMS_MACRO_GATE_STATE"])
        assert st["active"] is True and r["gate"]["active"] is True
        # gate_state 陈旧失效: 把 ymd 改到 30 天前
        env.params["PMS_MACRO_GATE_STATE"] = json.dumps(
            {"active": True, "ymd": mr.ymd_plus_days(days[-1], -30)})
        ms._gate_cache["at"] = 0.0
        assert ms.gate_state()["active"] is False
        # 关总开关立即失效
        env.params["PMS_MACRO_GATE_STATE"] = json.dumps(
            {"active": True, "ymd": __import__("app.core.tradedays",
                                               fromlist=["ymd"]).ymd()})
        env.params["PMS_MACRO_ENABLED"] = False
        ms._gate_cache["at"] = 0.0
        assert ms.gate_state()["active"] is False
    finally:
        env.uninstall()


@case("服务·数据不可用: 不动作不落闸, 行记 UNAVAILABLE")
def _():
    env = _Env()
    days = _mkdays(30)
    env.inputs = {"zs": [(d, 3000.0 + i) for i, d in enumerate(days)],
                  "fx": [], "shibor": []}
    env.install()
    try:
        now = __import__("datetime").datetime.strptime(str(days[-1]), "%Y%m%d")
        r = ms.scan(now=now)
        row = env.rows[("hedge_fx", days[-1])]
        assert row["zone"] == mr.Z_UNAVAILABLE and not env.issued, row
        st = json.loads(env.saved["PMS_MACRO_GATE_STATE"])
        assert st["active"] is False
        assert r["ok"], r
    finally:
        env.uninstall()


@case("闸·proposal_service 过滤: 拦买入侧留 TRIM; 读闸炸了按不拦")
def _():
    # 直接验证 scan_and_route 里那段过滤逻辑等价的行为: 用 gate_state 打桩
    orig = ms.gate_state
    ms.gate_state = lambda: {"active": True, "why": "单测"}
    try:
        g = proposal_service._macro_gate()
        assert g.get("active") is True
        cands = [{"ts_code": "A", "action": "ADD", "side": "buy"},
                 {"ts_code": "B", "action": "TRIM", "side": "sell"},
                 {"ts_code": "C", "action": "DCA", "side": "buy"}]
        kept, skipped = [], []
        for c in cands:                            # 与 scan_and_route 中的过滤同构
            (skipped if c.get("side") == "buy" else kept).append(c)
        assert [c["ts_code"] for c in kept] == ["B"]
        assert len(skipped) == 2
    finally:
        ms.gate_state = orig
    # 读闸抛异常 → _macro_gate 返回空 dict (不拦)
    ms.gate_state = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
    try:
        assert proposal_service._macro_gate() == {}
    finally:
        ms.gate_state = orig


@case("闸·strategy_runner 判定: 闸生效等价 buy_paused, 读不到按 False")
def _():
    from app.services import strategy_runner as srun
    orig = ms.gate_state
    ms.gate_state = lambda: {"active": True}
    try:
        assert srun._macro_gate_active() is True
    finally:
        ms.gate_state = orig
    ms.gate_state = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
    try:
        assert srun._macro_gate_active() is False
    finally:
        ms.gate_state = orig


@case("冷却·按信号历史行数计交易日距离, 零方案命令不占冷却")
def _():
    hist = [{"trade_date": 20260805, "action": "NONE", "ref_id": "", "detail": {}},
            {"trade_date": 20260804, "action": "NONE", "ref_id": "", "detail": {}},
            {"trade_date": 20260803, "action": "CMD_ISSUED", "ref_id": "C1",
             "detail": {"advice": {"direction": "REDUCE"}}}]
    # C1 在途 EXECUTING
    orig = pms_repo.get_command
    pms_repo.get_command = lambda cid: {"command_id": cid, "status": "EXECUTING",
                                        "cmd_type": "REDUCE_EXPOSURE"}
    try:
        inflight, last_red, last_inc = ms._action_history(hist)
        assert inflight and last_red == 20260803 and last_inc is None
        # 今天 0805 (行已在): 距 0803 过了 2 个交易日 → 冷却 3 剩 1
        assert ms._cooldown_left(hist, last_red, 20260805, 3) == 1
        # 今天 0806 首扫 (行未写): 距离 +1 → 剩 0
        assert ms._cooldown_left(hist, last_red, 20260806, 3) == 0
        # 零方案: 命令被立刻取消 → 不算生效动作
        pms_repo.get_command = lambda cid: {"command_id": cid, "status": "CANCELLED",
                                            "cmd_type": "REDUCE_EXPOSURE"}
        inflight, last_red, _li = ms._action_history(hist)
        assert not inflight and last_red is None
    finally:
        pms_repo.get_command = orig


@case("服务·休假模式与总开关: 扫描整体跳过")
def _():
    env = _Env(PMS_GLOBAL_EXEC_HALT=True)
    env.inputs, today = _cold_inputs()
    env.install()
    try:
        r = ms.scan()
        assert not env.issued and any("休假" in s for s in r["skipped"]), r
        env.params["PMS_GLOBAL_EXEC_HALT"] = False
        env.params["PMS_MACRO_ENABLED"] = False
        r2 = ms.scan()
        assert any("总开关" in s for s in r2["skipped"]), r2
    finally:
        env.uninstall()


# ================================================================ runner
def main():
    passed = failed = 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()
