# -*- coding: utf-8 -*-
"""
装配自检 (零外部依赖, 不连库)
==============================
运行: python scripts/test_wiring.py

用内存桩替换 repo/行情, 验证「服务层 → 核心逻辑 → 落表」整条链路的接线是否正确:
导入链、API 路由、调度表与守卫、单表访问守卫、交易日历、参数中心回退,
以及命令下达→方案落表→撤销、成交回放→批次入账→成本重算 两条主干流程。
"""
import os
import sys
import traceback
from datetime import date, datetime

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

RESULTS = []


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


# ================================================================ 内存桩
class FakeRepo:
    """pms_repo 的内存替身 (只实现服务层用到的函数, 语义与 SQL 版一致)。"""

    def __init__(self):
        self.params, self.commands, self.plans = {}, {}, []
        self.positions, self.lots, self.instructions = {}, [], {}
        self.proposals, self.ledger, self.reports, self.industry = {}, [], {}, {}
        self.cash_flows = []
        self._lot_id = 0

    # --- runtime param ---
    def all_params(self):
        return {k: {"param_value": v, "updated_by": "test", "updated_at": ""}
                for k, v in self.params.items()}

    def set_param(self, key, value, updated_by="user"):
        self.params[key] = str(value)
        return 1

    def get_param(self, key):
        return self.params.get(key)

    # --- command ---
    def next_command_seq(self, ymd):
        return len(self.commands) + 1

    def insert_command(self, *, command_id, cmd_class, cmd_type, ts_code, params, status,
                       issued_by=None, note=None, progress=None):
        kw = {"command_id": command_id, "cmd_class": cmd_class, "cmd_type": cmd_type,
              "ts_code": ts_code, "params": params, "status": status,
              "issued_by": issued_by, "note": note, "progress": progress}
        self.commands[kw["command_id"]] = {
            "id": len(self.commands) + 1, "command_id": kw["command_id"],
            "cmd_class": kw["cmd_class"], "cmd_type": kw["cmd_type"], "ts_code": kw["ts_code"],
            "params": kw["params"], "status": kw["status"], "progress": kw.get("progress") or {},
            "issued_by": kw.get("issued_by"), "issued_at": datetime.now(), "done_at": None,
            "note": kw.get("note")}
        return 1

    def get_command(self, cid):
        return self.commands.get(cid)

    def list_commands(self, *, statuses=None, cmd_class=None, limit=200):
        out = [c for c in self.commands.values()
               if (not statuses or c["status"] in statuses)
               and (not cmd_class or c["cmd_class"] == cmd_class)]
        return sorted(out, key=lambda c: -c["id"])[:limit]

    def update_command(self, cid, *, status=None, progress=None, done_at=None, note=None):
        c = self.commands.get(cid)
        if not c:
            return 0
        if status is not None:
            c["status"] = status
        if progress is not None:
            c["progress"] = progress
        if done_at is not None:
            c["done_at"] = done_at
        if note is not None:
            c["note"] = note
        return 1

    def supersede_param_commands(self, cmd_type, ts_code=None, keep_command_id=None):
        n = 0
        for c in self.commands.values():
            if (c["cmd_class"] == "param" and c["status"] == "EFFECTIVE"
                    and c["cmd_type"] == cmd_type and c["ts_code"] == ts_code
                    and c["command_id"] != keep_command_id):
                c["status"] = "SUPERSEDED"
                n += 1
        return n

    def latest_effective(self, cmd_type, ts_code=None):
        hits = [c for c in self.commands.values()
                if c["cmd_class"] == "param" and c["status"] == "EFFECTIVE"
                and c["cmd_type"] == cmd_type and (not ts_code or c["ts_code"] == ts_code)]
        return sorted(hits, key=lambda c: c["id"])[-1] if hits else None

    def list_effective_stock_params(self, cmd_types=None):
        return [c for c in self.commands.values()
                if c["cmd_class"] == "param" and c["status"] == "EFFECTIVE" and c["ts_code"]
                and (not cmd_types or c["cmd_type"] in cmd_types)]

    # --- plan ---
    def insert_plans(self, rows):
        for r in rows:
            d = dict(r)
            d.setdefault("filled_qty", 0)
            self.plans.append(d)
        return len(rows)

    def list_plans(self, *, command_id=None, statuses=None, ts_code=None, limit=500):
        out = [p for p in self.plans
               if (not command_id or p["command_id"] == command_id)
               and (not statuses or p["status"] in statuses)
               and (not ts_code or p["ts_code"] == ts_code)]
        return sorted(out, key=lambda p: p.get("priority", 100))[:limit]

    def update_plan(self, plan_id, *, status=None, filled_qty=None):
        for p in self.plans:
            if p["plan_id"] == plan_id:
                if status:
                    p["status"] = status
                if filled_qty is not None:
                    p["filled_qty"] = filled_qty
                return 1
        return 0

    def cancel_plans_of_command(self, cid):
        n = 0
        for p in self.plans:
            if p["command_id"] == cid and p["status"] in ("PENDING", "EXECUTING"):
                p["status"] = "CANCELLED"
                n += 1
        return n

    def set_plans_deadline(self, cid, dl):
        for p in self.plans:
            if p["command_id"] == cid:
                p["deadline"] = dl
        return 1

    # --- position / lot ---
    def list_positions(self, *, only_open=False):
        out = list(self.positions.values())
        return [p for p in out if int(p.get("total_qty") or 0) > 0] if only_open else out

    def get_position(self, code):
        return self.positions.get(code)

    def ensure_position(self, code):
        self.positions.setdefault(code, {"ts_code": code, "status": "PLANNED", "total_qty": 0,
                                         "avail_qty": 0, "frozen_reason": "NONE"})
        return 1

    def update_position(self, code, **fields):
        self.ensure_position(code)
        self.positions[code].update(fields)
        return 1

    def bump_position_qty(self, code, *, total_delta=0, avail_delta=0):
        self.ensure_position(code)
        p = self.positions[code]
        p["total_qty"] = max(0, int(p.get("total_qty") or 0) + total_delta)
        p["avail_qty"] = max(0, int(p.get("avail_qty") or 0) + avail_delta)
        return 1

    def reset_avail_all(self):
        for p in self.positions.values():
            p["avail_qty"] = p.get("total_qty", 0)
            p["t0_count_today"] = 0
        return len(self.positions)

    def list_lots(self, ts_code=None, *, status="OPEN", limit=1000):
        out = [l for l in self.lots
               if (not ts_code or l["ts_code"] == ts_code)
               and (not status or l["status"] == status)]
        return sorted(out, key=lambda l: (str(l["open_date"]), l["id"]))[:limit]

    def insert_lot(self, *, ts_code, lot_type, qty, open_price, open_date, instruction_id=None,
                   note=None):
        self._lot_id += 1
        self.lots.append({"id": self._lot_id, "ts_code": ts_code, "lot_type": lot_type,
                          "qty": int(qty), "open_price": float(open_price),
                          "open_date": open_date, "closed_qty": 0, "close_avg_price": None,
                          "realized_pnl": 0.0, "status": "OPEN",
                          "instruction_id": instruction_id, "note": note})
        return 1

    def close_lot_qty(self, lot_id, *, qty, close_price, realized_pnl):
        for l in self.lots:
            if l["id"] == lot_id:
                old_closed = int(l["closed_qty"])
                l["close_avg_price"] = ((float(l["close_avg_price"] or 0) * old_closed
                                         + close_price * qty) / (old_closed + qty))
                l["qty"] = max(0, int(l["qty"]) - qty)
                l["closed_qty"] = old_closed + qty
                l["realized_pnl"] += realized_pnl
                l["status"] = "CLOSED" if l["qty"] <= 0 else "OPEN"
                return 1
        return 0

    def update_lot(self, lot_id, **fields):
        for l in self.lots:
            if l["id"] == lot_id:
                l.update(fields)
                return 1
        return 0

    # --- instruction / proposal / ledger / report / industry ---
    # --- pms_cash_flow (第 14 张表; 费用只进现金账, 绝不摊成本 —— 协议 §5.5) ---
    def insert_cash_flow(self, *, ymd, kind, amount, ts_code=None, estimated=0,
                         trade_no=None, instruction_id=None, note=None):
        self.cash_flows.append({"ymd": ymd, "kind": kind, "amount": float(amount),
                                "ts_code": ts_code, "estimated": int(bool(estimated)),
                                "trade_no": trade_no, "instruction_id": instruction_id,
                                "note": note})
        return 1

    def insert_instruction(self, *, instruction_id, origin_type, origin_id, ts_code, action,
                           side, qty, limit_price=None, window_tdays=3, status="PROPOSED",
                           progress=None):
        kw = {"instruction_id": instruction_id, "origin_type": origin_type,
              "origin_id": origin_id, "ts_code": ts_code, "action": action, "side": side,
              "qty": qty, "limit_price": limit_price, "window_tdays": window_tdays,
              "status": status, "progress": progress or {}}
        kw.setdefault("exec_qty", 0)
        kw["created_at"] = kw["updated_at"] = datetime.now()
        self.instructions[kw["instruction_id"]] = kw
        return 1

    def list_instructions(self, *, statuses=None, side=None, ts_code=None, limit=300):
        out = [i for i in self.instructions.values()
               if (not statuses or i["status"] in statuses)
               and (not side or i.get("side") == side)
               and (not ts_code or i["ts_code"] == ts_code)]
        return out[:limit]

    def get_instruction(self, iid):
        return self.instructions.get(iid)

    # **签名必须跟真 repo 一模一样**, 不许写成 `**kw`。2026-08-03 实机: executor 多传了
    # 一个 limit_price=None, 真 repo 抛 TypeError、单子已发出而本端没记账, 而这里 `**kw`
    # 全收所以单测一路绿。桩比真依赖宽松 = 单测在替真依赖打掩护。
    # test_batch10 的 [M] 组会扫这一条, 别改回 **kw。
    def update_instruction(self, iid, *, status=None, exec_qty=None, exec_avg_price=None,
                           dispatch_ref=None, progress=None):
        i = self.instructions.get(iid)
        if not i:
            return 0
        for k, v in (("status", status), ("exec_qty", exec_qty),
                     ("exec_avg_price", exec_avg_price), ("dispatch_ref", dispatch_ref),
                     ("progress", progress)):
            if v is not None:
                i[k] = v
        i["updated_at"] = datetime.now()
        return 1

    def add_instruction_exec(self, iid, qty):
        i = self.instructions.get(iid)
        if i:
            i["exec_qty"] = int(i.get("exec_qty") or 0) + int(qty)
        return 1

    def insert_proposal(self, *, proposal_id, ts_code, action, qty, hard_numbers, expire_at,
                        judge_verdict=None, judge_reason=None, status="WAIT_USER"):
        kw = {"proposal_id": proposal_id, "ts_code": ts_code, "action": action, "qty": qty,
              "hard_numbers": hard_numbers, "expire_at": expire_at,
              "judge_verdict": judge_verdict, "judge_reason": judge_reason, "status": status}
        kw["status"] = kw.get("status", "WAIT_USER")
        kw["hard_numbers"] = kw.get("hard_numbers") or {}
        self.proposals[kw["proposal_id"]] = kw
        return 1

    def list_proposals(self, *, statuses=("WAIT_USER",), limit=200):
        return [p for p in self.proposals.values() if p["status"] in statuses][:limit]

    def get_proposal(self, pid):
        return self.proposals.get(pid)

    def decide_proposal(self, pid, status):
        p = self.proposals.get(pid)
        if p and p["status"] == "WAIT_USER":
            p["status"] = status
            return 1
        return 0

    def expire_proposals(self, now=None):
        return 0

    def insert_ledger(self, *, ts_code, action, arbiter, verdict, price_at, hard_numbers=None,
                      failed_checks=None, reason=None, ref_id=None):
        kw = {"ts_code": ts_code, "action": action, "arbiter": arbiter, "verdict": verdict,
              "price_at": price_at, "hard_numbers": hard_numbers or {},
              "failed_checks": failed_checks or [], "reason": reason, "ref_id": ref_id,
              "decided_at": datetime.now()}
        self.ledger.append(kw)
        return 1

    def list_ledger(self, *, ts_code=None, limit=200):
        return self.ledger[-limit:]

    def rule_rejected_today(self, since):
        # 内存桩里所有留痕都算"今天"; 只认规则闸的 REJECT (研判结论会变, 不参与当日去重)
        return {(r["ts_code"], r["action"]) for r in self.ledger
                if r.get("verdict") == "REJECT" and r.get("arbiter") == "rule"}

    def upsert_report(self, ymd, report):
        self.reports[int(ymd)] = report
        return 1

    def get_report(self, ymd):
        r = self.reports.get(int(ymd))
        return {"ymd": ymd, "report": r} if r else None

    def latest_report(self):
        if not self.reports:
            return None
        y = max(self.reports)
        return {"ymd": y, "report": self.reports[y]}

    def get_industry(self, code):
        return self.industry.get(code)

    def list_industry(self, limit=5000):
        return [{"ts_code": k, "industry": v} for k, v in self.industry.items()]

    def upsert_industry(self, rows):
        for r in rows:
            self.industry[r["ts_code"]] = r["industry"]
        return len(rows)


class FakeQmtRepo:
    """ws 通道三表的内存替身 (pms_qmt_order / pms_qmt_inbox / pms_ws_state)。

    只实现 dispatcher 用得到的那几个 —— 出栈、落库、seq 水位是常驻进程 (app/ws/runner.py)
    的事, 由 test_batch6_units.py 的纯逻辑用例覆盖; 这里只管「服务层接线对不对」。
    """
    LIVE = ("QUEUED", "SENDING", "SENT", "ACCEPTED", "SUBMITTED", "PARTIAL")

    def __init__(self):
        self.state = {"id": 1, "last_seq": 0, "acked_seq": 0, "server_seq": 0,
                      "conn_state": "INIT", "heartbeat_at": None, "connected_at": None,
                      "resync_flag": 0, "last_error": None, "stat": {}}
        self.orders = {}
        self.inbox = {}                 # seq → 上行消息行 (pms_qmt_inbox)
        self.snapshots = []             # snapshot 上行 (对账的事实源, §5.7)

    def set_channel(self, conn_state="ONLINE", alive=True):
        """摆一个通道状态出来 —— 「进程活没活」和「连接通没通」是两件事。"""
        self.state["conn_state"] = conn_state
        self.state["heartbeat_at"] = datetime.now() if alive else None

    # ---- 以下与真 repo 同名同义 ----
    def get_state(self):
        return dict(self.state)

    def inbox_pending_count(self):
        return 0

    def queue_depth(self):
        d = {}
        for o in self.orders.values():
            d[o["status"]] = d.get(o["status"], 0) + 1
        return d

    def enqueue_order(self, *, instruction_id, parent_id, ts_code, side, qty, limit_price,
                      valid_until, intent="OPEN", note=None):
        self.orders[instruction_id] = {
            "instruction_id": instruction_id, "parent_id": parent_id,
            # 真表的列名是 parent_instruction_id, consume_ws_trades 反查用的是它。
            # 桩里两个键都放, 少一个的话 SMOKE_ 那道闸在单测里永远"看起来没生效"。
            "parent_instruction_id": parent_id, "ts_code": ts_code,
            "side": side, "qty": int(qty), "limit_price": float(limit_price),
            "valid_until": int(valid_until), "intent": intent, "note": note,
            "status": "QUEUED", "cancel_state": "NONE", "cancel_id": None}
        return 1

    def request_cancel(self, *, parent_id, cancel_id):
        n = 0
        for o in self.orders.values():
            if (o["parent_id"] == parent_id and o["cancel_state"] == "NONE"
                    and o["status"] in self.LIVE):
                o.update({"cancel_state": "REQUESTED", "cancel_id": cancel_id})
                n += 1
        return n

    def get_order(self, instruction_id):
        return self.orders.get(instruction_id)

    # ---- pms_qmt_inbox ----
    def put_trade(self, *, seq, instruction_id, ts_code, side, qty, price, fee=0.0,
                  trade_no=None, processed=0):
        """塞一笔 trade 上行。测试专用, 真 repo 里对应的是 runner 的落库路径。"""
        self.inbox[int(seq)] = {
            "seq": int(seq), "msg_type": "trade", "processed": int(processed),
            "process_note": None,
            "payload": {"instruction_id": instruction_id, "ts_code": ts_code, "side": side,
                        "qty": int(qty), "price": float(price),
                        "amount": round(float(price) * int(qty), 2), "fee": float(fee),
                        "trade_no": trade_no or f"T-{seq}"}}
        return self.inbox[int(seq)]

    def inbox_pending(self, limit=500):
        rows = [r for r in sorted(self.inbox.values(), key=lambda x: x["seq"])
                if int(r["processed"]) == 0]
        return [dict(r, payload=dict(r["payload"])) for r in rows[:int(limit)]]

    def inbox_mark(self, seqs, processed=1, note=None):
        n = 0
        for s in (seqs or []):
            if int(s) in self.inbox:
                self.inbox[int(s)].update({"processed": int(processed), "process_note": note})
                n += 1
        return n

    def inbox_orphan_count(self):
        return sum(1 for r in self.inbox.values() if int(r["processed"]) == 3)

    def put_snapshot(self, *, kind="positions", items=None, data=None, age_sec=0, seq=None):
        """塞一条 snapshot 上行 (§5.7)。age_sec 用来造"快照过期"。"""
        seq = int(seq if seq is not None else (max(self.inbox) + 1 if self.inbox else 1000))
        pl = {"kind": kind, "as_of": 1769500000000}
        if items is not None:
            pl["items"] = items
        if data is not None:
            pl["data"] = data
        self.snapshots.append({"seq": seq, "payload": pl, "age_sec": float(age_sec),
                               "received_at": datetime.now()})
        return pl

    def latest_snapshot(self, kind, *, scan=60):
        for s in sorted(self.snapshots, key=lambda x: x["seq"], reverse=True):
            if str(s["payload"].get("kind") or "").lower() == str(kind).lower():
                return dict(s)
        return None


def install_fakes(prices=None, positions=None, params=None, high5=None, prev_close=None):
    """把内存桩装到各模块上, 返回 FakeRepo 实例 (ws 通道桩挂在 .qmt 上)。"""
    from app.repo import downstream_repo, pms_repo, qmt_repo
    from app.services import industry, market, param_store, portfolio

    fake = FakeRepo()
    fake.qmt = FakeQmtRepo()
    for _n in ("get_state", "inbox_pending_count", "queue_depth", "enqueue_order",
               "request_cancel", "get_order", "inbox_pending", "inbox_mark",
               "inbox_orphan_count", "latest_snapshot"):
        setattr(qmt_repo, _n, getattr(fake.qmt, _n))
    fake.params.update(params or {})
    for p in (positions or []):
        base = {"ts_code": p["ts_code"], "status": "HOLDING", "frozen_reason": "NONE",
                "total_qty": 0, "avail_qty": 0, "base_qty": 0, "fill_qty": 0, "add_qty": 0,
                "dca_qty": 0, "t0_qty": 0, "avg_cost": None, "cushion_peak": 0,
                "realized_t_profit": 0, "t0_enabled": 0}
        base.update(p)
        fake.positions[p["ts_code"]] = base

    for mod in (pms_repo,):
        for name in dir(FakeRepo):
            if name.startswith("_"):
                continue
            setattr(mod, name, getattr(fake, name))
    market.get_price = lambda c: (prices or {}).get(c)
    market.get_prices = lambda cs_: {c: (prices or {}).get(c) for c in (cs_ or [])}
    market.get_refs = lambda c, **kw: {"support": None, "pressure": None, "stop": None,
                                       "source": "none"}
    market.get_ma5 = lambda c: (prices or {}).get(c)
    # plan_price 是规划期定价 (实时价 → 昨收兜底)。桩不接库, 一律按"有实时价"处理;
    # 昨收那条路单独给 prev_close 参数的用例覆盖。
    market.get_last_close = lambda c: (prev_close or {}).get(c)
    market.plan_price = lambda c: (
        {"price": (prices or {}).get(c), "source": "realtime"} if (prices or {}).get(c)
        else ({"price": (prev_close or {}).get(c), "source": "prev_close"}
              if (prev_close or {}).get(c) else {"price": None, "source": "none"}))
    market.get_high5 = lambda c: (high5 or prices or {}).get(c)
    market.day_snapshot = lambda c: ({} if not (prices or {}).get(c) else {
        "price": prices[c], "vwap": prices[c], "open": prices[c], "high": prices[c] * 1.02,
        "low": prices[c] * 0.98, "day_chg_from_open": 0.0, "bars": 60})
    industry.get_many = lambda cs_: {c: None for c in (cs_ or [])}
    industry.get = lambda c: None
    industry.ready = lambda: False
    industry.status = lambda: {"source": "", "ready": False, "count": 0, "hint": "test"}
    # 下游只读表默认桩 (单测不触网; 个别用例内再按需覆盖)
    downstream_repo.fetch_buy_plans = lambda **kw: []
    downstream_repo.fetch_filled_orders = lambda **kw: []
    downstream_repo.fetch_positions = lambda: {"rows": [], "columns": {"qty": None},
                                               "raw_count": 0}
    downstream_repo.fetch_refs = lambda c: None
    downstream_repo.category_columns = lambda force=False: {
        "columns": None, "code_col": None, "industry_col": None,
        "error": "单测桩: 未接行业表"}
    from app.repo import industry_repo
    industry_repo.probe = lambda force=False: {"form": None, "error": "单测桩: 未接 gp_hybk",
                                               "columns": None, "tried": []}
    industry_repo.primary_industry_map = lambda codes, level="l3": {}
    industry_repo.invalidate = lambda: None
    downstream_repo.latest_filled_order_id = lambda: "ANCHOR_0"
    # 回放游标预置成非空 —— 否则 replay_fills 会走「冷启动只对齐不追认」那条路 (见
    # ledger_service._seed_cursor), 下面那几个回放用例就测不到入账。冷启动本身另有专门用例。
    # 用 "0" 而不是随便一个字符串: next_cursor 只进不退, 且非数字 id 会退化成字典序比较,
    # 占位值若比真实 order_id 大 (比如 "SEED"), 游标就永远推不动了。
    fake.params.setdefault("PMS_REPLAY_CURSOR", "0")
    # 上游选股计划接口在单测里一律停用 (base 为空 → plan_feed 立即抛 PlanFeedError,
    # 不会发出任何 HTTP 请求)。要测候选池的用例自己 stub plan_feed.candidates。
    fake.params.setdefault("PMS_PLAN_API_BASE", "")
    param_store._cache.update({"at": 0.0, "data": {}, "error": None, "loaded": False})
    portfolio.save_neg_streak({})
    return fake


# ================================================================ 用例
@case("装配·全部模块可导入 (无循环依赖)")
def _():
    import importlib
    for m in ["app.core.sizer", "app.core.cushion", "app.core.command_spec", "app.core.planner",
              "app.core.recon", "app.core.tradedays", "app.db.session", "app.repo.pms_repo",
              "app.repo.downstream_repo", "app.services.param_store", "app.services.industry",
              "app.services.market", "app.services.plan_feed",
              "app.services.portfolio", "app.services.command_service",
              "app.services.ledger_service", "app.web.main", "app.scheduler"]:
        importlib.import_module(m)


@case("装配·API 路由齐全 (四块页面 + 运维)")
def _():
    from app.web.main import app
    paths = {r.path for r in app.routes}
    need = ["/", "/health", "/api/overview", "/api/params", "/api/commands",
            "/api/commands/catalog", "/api/commands/active", "/api/commands/{command_id}",
            "/api/commands/{command_id}/cancel", "/api/plans", "/api/positions",
            "/api/positions/{ts_code}/lots", "/api/instructions", "/api/ledger", "/api/report",
            "/api/proposals", "/api/proposals/{proposal_id}/decide", "/api/ops/replay",
            "/api/ops/reconcile", "/api/ops/daily-settle", "/api/ops/report",
            "/api/ops/downstream-schema", "/api/industry",
            "/api/upstream/plan", "/api/ops/plan-refresh"]
    missing = [p for p in need if p not in paths]
    assert not missing, missing
    import os as _os
    from app.web import main as web
    assert _os.path.exists(_os.path.join(web.STATIC_DIR, "index.html")), "单页文件缺失"


@case("装配·调度表覆盖设计 §10 全部调度位")
def _():
    from app import scheduler as sch
    names = set(sch.celery_app.conf.beat_schedule)
    assert names == {"plan_pull", "premarket", "command_poll", "replay_fills",
                     "intraday_exec", "signal_digest", "t0_close", "daily_settle",
                     "daily_report",
                     "macro_scan"}, names   # 2026-08-18 +宏观择时 09:35
    tasks = set(sch.celery_app.tasks)
    for t in ("pms.premarket", "pms.command_poll", "pms.replay_fills", "pms.daily_settle",
              "pms.daily_report", "pms.t0_close", "pms.intraday_exec", "pms.signal_digest",
              "pms.plan_pull", "pms.macro_scan"):
        assert t in tasks, t
    assert sch.in_session(datetime(2026, 7, 27, 10, 0)) is True
    assert sch.in_session(datetime(2026, 7, 27, 12, 0)) is False
    assert sch.in_session(datetime(2026, 7, 27, 14, 59)) is True


@case("装配·调度守卫: 非交易日跳过 / 休假模式跳过 / 异常不外抛")
def _():
    from app import scheduler as sch
    from app.core import tradedays as tdm
    orig = tdm.is_trade_day
    try:
        tdm.is_trade_day = lambda d=None: False

        @sch.guard(trade_day=True)
        def t1():
            raise AssertionError("非交易日不应执行")
        assert t1() == {"skipped": "not_trade_day"}

        tdm.is_trade_day = lambda d=None: True
        install_fakes(params={"PMS_GLOBAL_EXEC_HALT": "true"})

        @sch.guard(trade_day=True)
        def t2():
            raise AssertionError("休假模式不应执行")
        assert t2() == {"skipped": "exec_halt"}

        install_fakes(params={})

        @sch.guard(trade_day=True)
        def t3():
            raise ValueError("boom")
        r = t3()
        assert "error" in r and "boom" in r["error"], r
    finally:
        tdm.is_trade_day = orig


@case("装配·严格单表访问守卫 (JOIN 与多表一律拒绝)")
def _():
    from app.db.session import MultiTableSQL, assert_single_table
    assert_single_table("SELECT * FROM pms_position WHERE ts_code = :c")
    assert_single_table("UPDATE pms_lot SET qty = 0 WHERE id = :id")
    for bad in ["SELECT a.* FROM pms_position a JOIN pms_lot b ON a.ts_code = b.ts_code",
                "SELECT * FROM pms_position, pms_lot",
                "SELECT * FROM pms_plan WHERE ts_code IN (SELECT ts_code FROM pms_position)"]:
        try:
            assert_single_table(bad)
            assert False, f"未拦截: {bad}"
        except MultiTableSQL:
            pass


@case("交易日历·执行窗口与剩余交易日 (跨周末)")
def _():
    from app.core import tradedays as tdm
    fri, mon = date(2026, 7, 24), date(2026, 7, 27)      # 2026-07-24 周五, 07-27 周一
    assert tdm.is_trade_day(fri) and tdm.is_trade_day(mon)
    assert tdm.is_trade_day(date(2026, 7, 25)) is False  # 周六
    assert tdm.next_trade_day(fri, 1) == mon
    assert tdm.window_deadline(mon, 1) == mon
    assert tdm.window_deadline(mon, 3) == date(2026, 7, 29)
    assert tdm.window_deadline(fri, 2) == mon            # 周五起 2 个交易日 = 周一
    assert tdm.trade_days_left(date(2026, 7, 29), mon) == 3
    assert tdm.trade_days_left(date(2026, 7, 20), mon) == 0
    assert tdm.ymd(mon) == 20260727


@case("参数中心·表值优先/文件回退/非法值拒绝")
def _():
    from app.services import param_store
    from config.settings import settings
    fake = install_fakes(params={"PMS_TOTAL_SCALE": "3000000", "PMS_AUTONOMY": "full"})
    assert param_store.get("PMS_TOTAL_SCALE") == 3_000_000.0
    assert param_store.get("PMS_AUTONOMY") == "full"
    assert param_store.get("PMS_STOCK_CAP") == settings.PMS_STOCK_CAP      # 未改写 → 文件初值
    assert param_store.get_bool("PMS_GLOBAL_BUY_HALT") is False            # 运行态默认
    assert param_store.set_param("PROXY_DB_URL", "x")["ok"] is False       # 基础设施不可改
    assert param_store.set_param("PMS_PORTFOLIO_CAP", 1.5)["ok"] is False  # 越界
    assert param_store.set_param("PMS_AUTONOMY", "auto")["ok"] is False    # 枚举非法
    assert param_store.set_param("PMS_PORTFOLIO_CAP", 0.5)["ok"] is True
    assert fake.params["PMS_PORTFOLIO_CAP"] == "0.5"
    assert param_store.get("PMS_PORTFOLIO_CAP") == 0.5
    sp = param_store.sizing_params()
    assert sp["scale"] == 3_000_000.0 and sp["portfolio_cap"] == 0.5


@case("命令服务·参数命令立即生效并覆盖旧记录")
def _():
    from app.services import command_service as csvc, param_store
    fake = install_fakes()
    r1 = csvc.issue("SET_SCALE", {"scale": 2_000_000})
    assert r1["ok"] and r1["status"] == "EFFECTIVE", r1
    assert param_store.get("PMS_TOTAL_SCALE") == 2_000_000.0
    r2 = csvc.issue("SET_SCALE", {"scale": 2_500_000})
    assert r2["ok"] and param_store.get("PMS_TOTAL_SCALE") == 2_500_000.0
    assert fake.commands[r1["command_id"]]["status"] == "SUPERSEDED"
    assert fake.commands[r2["command_id"]]["status"] == "EFFECTIVE"
    bad = csvc.issue("SET_SCALE", {"scale": -1})
    assert not bad["ok"] and any("OUT_OF_RANGE" in e for e in bad["errors"]), bad


@case("命令服务·个股参数命令投影到账本 (冻结/做T/止损价)")
def _():
    from app.services import command_service as csvc
    fake = install_fakes(positions=[{"ts_code": "600000.SH", "total_qty": 1000,
                                     "avg_cost": 10.0}])
    assert csvc.issue("FREEZE_STOCK", {"ts_code": "600000.SH"})["ok"]
    assert fake.positions["600000.SH"]["frozen_reason"] == "COMMAND_HALT"
    assert csvc.issue("T0_ENABLE", {"ts_code": "600000.SH", "t_ratio": "30%"})["ok"]
    assert fake.positions["600000.SH"]["t0_enabled"] == 1
    assert abs(float(fake.positions["600000.SH"]["t0_ratio"]) - 0.30) < 1e-9
    assert csvc.issue("SET_STOP_PRICE", {"ts_code": "600000.SH", "price": 9.2})["ok"]
    assert fake.positions["600000.SH"]["stop_ref"] == 9.2
    assert fake.positions["600000.SH"]["ref_source"] == "user"
    assert csvc.issue("UNFREEZE_STOCK", {"ts_code": "600000.SH"})["ok"]
    assert fake.positions["600000.SH"]["frozen_reason"] == "NONE"
    sp = csvc.effective_stock_params()["600000.SH"]
    assert sp["frozen"] is False and sp["t0"] is True and sp["stop_price"] == 9.2, sp
    # 做T比例超硬上限 1/3 被拒
    assert not csvc.issue("T0_ENABLE", {"ts_code": "600000.SH", "t_ratio": "40%"})["ok"]


@case("命令服务·降仓命令端到端 (方案落表 + 进度 + 撤销)")
def _():
    from app.services import command_service as csvc
    fake = install_fakes(
        prices={"600000.SH": 10.0, "000001.SZ": 8.0},
        params={"PMS_TOTAL_SCALE": "2000000", "PMS_WEAK_NEG_DAYS": "5"},
        positions=[{"ts_code": "600000.SH", "total_qty": 14000, "base_qty": 7000,
                    "fill_qty": 3500, "add_qty": 3500, "avg_cost": 8.93},
                   {"ts_code": "000001.SZ", "total_qty": 10000, "base_qty": 10000,
                    "avg_cost": 8.8}])
    from app.services import portfolio
    portfolio.save_neg_streak({"000001.SZ": 6})

    r = csvc.issue("REDUCE_EXPOSURE", {"pct": "5%", "window_tdays": 3})
    assert r["ok"] and r["status"] == "EXECUTING", r
    cid = r["command_id"]
    plans = fake.list_plans(command_id=cid)
    assert plans, "方案未落表"
    assert {p["action"] for p in plans} <= {"EXIT", "TRIM", "HALT"}, plans
    exit_p = [p for p in plans if p["action"] == "EXIT"]
    assert exit_p and exit_p[0]["ts_code"] == "000001.SZ", plans   # 弱票优先清
    assert all(p["deadline"] for p in plans)
    prog = fake.commands[cid]["progress"]
    assert prog["target_amount"] == 100_000.0 and prog["plan_count"] == len(plans), prog

    # 进度结算: 方案未成交 → 保持 EXECUTING; 全部成交 → DONE
    r2 = csvc.refresh_progress(cid)
    assert r2["commands"][0]["status"] in ("EXECUTING", "PARTIAL"), r2
    for p in plans:
        fake.update_plan(p["plan_id"], status="DONE", filled_qty=p["qty"])
    csvc.refresh_progress(cid)
    assert fake.commands[cid]["status"] == "DONE", fake.commands[cid]

    # 撤销: 已完成命令不可撤
    assert csvc.cancel(cid)["ok"] is False
    r3 = csvc.issue("REDUCE_EXPOSURE", {"pct": "3%"}, force_conflict=True)
    assert csvc.cancel(r3["command_id"])["ok"] is True
    assert fake.commands[r3["command_id"]]["status"] == "CANCELLED"
    assert all(p["status"] == "CANCELLED"
               for p in fake.list_plans(command_id=r3["command_id"]))


@case("命令服务·冲突拦截与强制下达")
def _():
    from app.services import command_service as csvc
    install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
                  positions=[{"ts_code": "600000.SH", "total_qty": 14000, "base_qty": 7000,
                              "avg_cost": 9.0}])
    a = csvc.issue("REDUCE_EXPOSURE", {"pct": "5%"})
    assert a["ok"], a
    b = csvc.issue("INCREASE_EXPOSURE", {"pct": "5%"})
    assert not b["ok"] and b["conflicts"], b
    assert any("方向相反" in c["reason"] for c in b["conflicts"])
    c = csvc.issue("INCREASE_EXPOSURE", {"pct": "5%"}, force_conflict=True)
    assert c["ok"] and c["conflicts"], c        # 强制下达但冲突仍留痕


@case("命令服务·全局暂停买入撤在途买入指令并置开关")
def _():
    from app.services import command_service as csvc, param_store
    fake = install_fakes(prices={"600000.SH": 10.0})
    fake.insert_instruction(instruction_id="INS_1", origin_type="plan", origin_id="P1",
                            ts_code="600000.SH", action="OPEN", side="buy", qty=1000,
                            limit_price=10.0, status="DISPATCHED")
    fake.insert_instruction(instruction_id="INS_2", origin_type="plan", origin_id="P2",
                            ts_code="600000.SH", action="EXIT", side="sell", qty=500,
                            limit_price=10.0, status="DISPATCHED")
    r = csvc.issue("HALT_BUY", {})
    assert r["ok"] and r["status"] == "DONE", r
    assert fake.instructions["INS_1"]["status"] == "CANCELLED"
    assert fake.instructions["INS_2"]["status"] == "DISPATCHED"    # 卖出不受影响
    assert param_store.get_bool("PMS_GLOBAL_BUY_HALT") is True
    assert csvc.issue("RESUME_BUY", {})["ok"]
    assert param_store.get_bool("PMS_GLOBAL_BUY_HALT") is False


@case("账本服务·成交回放入账 → 批次/持仓/成本重算")
def _():
    from app.services import ledger_service as ls
    from app.repo import downstream_repo
    fake = install_fakes(prices={"600000.SH": 11.0})
    fake.insert_instruction(instruction_id="INS_A", origin_type="plan", origin_id="P1",
                            ts_code="600000.SH", action="OPEN", side="buy", qty=6000,
                            limit_price=10.0, status="DISPATCHED")
    # 建单时点必须显式钉死: 用 datetime.now() 会让本用例的通过与否取决于跑测试的钟点
    # (时间守卫要求 下发 ≤ 成交), 17:12 跑就会误判成外部成交 —— 实机上已踩到。
    fake.instructions["INS_A"]["created_at"] = "2026-07-27 09:30:00"
    # updated_at 故意置在成交之后: 认领必须看建单时点, 不能看 updated_at
    # (否则部分成交回写一次 updated_at, 后续成交就会被自己挡住)
    fake.instructions["INS_A"]["updated_at"] = "2026-07-27 23:59:59"
    fills = [{"order_id": 101, "ts_code": "600000.SH", "side": "buy", "qty": 6000,
              "price": 10.0, "done_time": "2026-07-27 09:40:00"}]
    orig = downstream_repo.fetch_filled_orders
    try:
        downstream_repo.fetch_filled_orders = lambda **kw: fills
        r = ls.replay_fills()
        assert r["ok"] and r["actions"] == 1, r
        assert fake.positions["600000.SH"]["total_qty"] == 6000
        assert fake.positions["600000.SH"]["avail_qty"] == 0          # T+1: 当日买入不可卖
        assert abs(float(fake.positions["600000.SH"]["avg_cost"]) - 10.0) < 1e-6
        assert fake.positions["600000.SH"]["base_qty"] == 6000
        assert abs(float(fake.positions["600000.SH"]["cushion_pct"]) - 0.10) < 1e-4
        assert fake.instructions["INS_A"]["status"] == "CONFIRMED"
        assert fake.params["PMS_REPLAY_CURSOR"] == "101"
        # 幂等: 游标已推进, 同一批不再重复入账
        downstream_repo.fetch_filled_orders = lambda **kw: []
        r2 = ls.replay_fills()
        assert r2["fills"] == 0 and fake.positions["600000.SH"]["total_qty"] == 6000
    finally:
        downstream_repo.fetch_filled_orders = orig


@case("账本服务·卖出回放按核销次序 + 摊薄成本下降")
def _():
    from app.services import ledger_service as ls
    from app.repo import downstream_repo
    fake = install_fakes(prices={"600000.SH": 12.0})
    fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=6000, open_price=10.0,
                    open_date="2026-07-01")
    fake.insert_lot(ts_code="600000.SH", lot_type="ADD", qty=3000, open_price=11.0,
                    open_date="2026-07-20")
    fake.update_position("600000.SH", total_qty=9000, avail_qty=9000)
    fills = [{"order_id": 201, "ts_code": "600000.SH", "side": "sell", "qty": 3000,
              "price": 12.0, "done_time": "2026-07-27 10:00:00"}]
    orig = downstream_repo.fetch_filled_orders
    try:
        downstream_repo.fetch_filled_orders = lambda **kw: fills
        r = ls.replay_fills()
        assert r["ok"], r
        add_lot = [l for l in fake.lots if l["lot_type"] == "ADD"][0]
        assert add_lot["qty"] == 0 and add_lot["status"] == "CLOSED"   # ADD 先核销
        base_lot = [l for l in fake.lots if l["lot_type"] == "BASE"][0]
        assert base_lot["qty"] == 6000                                 # 底仓保留
        p = fake.positions["600000.SH"]
        assert p["total_qty"] == 6000 and p["avail_qty"] == 6000
        # 摊薄成本 = (60000+33000-36000)/6000 = 9.5
        assert abs(float(p["avg_cost"]) - 9.5) < 1e-6, p["avg_cost"]
    finally:
        downstream_repo.fetch_filled_orders = orig


@case("账本服务·首次回放只对齐游标, 不把下游历史当外部成交追认")
def _():
    from app.repo import downstream_repo
    from app.services import ledger_service as ls
    fake = install_fakes()
    fake.params.pop("PMS_REPLAY_CURSOR", None)          # 冷启动: 从没设过游标
    hist = [{"order_id": f"BUY_X_{i}", "ts_code": "600000.SH", "side": "buy", "qty": 100,
             "price": 10.0, "done_time": "2020-01-01 09:40:00"} for i in range(500)]
    orig = downstream_repo.fetch_filled_orders
    try:
        downstream_repo.fetch_filled_orders = lambda **kw: hist
        downstream_repo.latest_filled_order_id = lambda: "SELL_ZZZ_999"
        r = ls.replay_fills()
        # 关键: 一条都不能入账。旧系统多年的历史成交若被并入 BASE, 摊薄成本与安全垫全错,
        # 而补仓/加仓/保垫减仓都挂在安全垫上 —— 一错就是整条纪律链。
        assert r.get("seeded") and r["fills"] == 0 and r["actions"] == 0, r
        assert r["cursor"] == "SELL_ZZZ_999", r
        assert not fake.lots and not fake.positions, "冷启动不该产生任何批次或持仓"
        assert fake.params["PMS_REPLAY_CURSOR"] == "SELL_ZZZ_999"

        # 游标就位后, 新成交照常入账
        new = [{"order_id": "ZZZ_NEW", "ts_code": "600000.SH", "side": "buy", "qty": 100,
                "price": 10.0, "done_time": "2026-07-29 09:40:00"}]
        downstream_repo.fetch_filled_orders = lambda **kw: new
        r2 = ls.replay_fills()
        assert r2["ok"] and r2["actions"] == 1, r2

        # 显式全量: 游标设 ALL 才追认历史
        fake.params["PMS_REPLAY_CURSOR"] = ls.CURSOR_ALL
        downstream_repo.fetch_filled_orders = lambda **kw: hist[:3]
        r3 = ls.replay_fills()
        assert r3["fills"] == 3, r3
    finally:
        downstream_repo.fetch_filled_orders = orig


@case("自主提议·一次性守卫真的写得进去, 且当日被拒的不再每分钟重评")
def _():
    from datetime import date
    from app.repo import pms_repo
    from app.services import proposal_service as ps
    fake = install_fakes(positions=[{"ts_code": "600000.SH", "total_qty": 6000,
                                     "avail_qty": 6000, "base_qty": 6000, "avg_cost": 10.0}])
    # 三个计数器此前只被读、从没被写过 —— 设计 §6 的三条一次性约束等于一直没生效
    ps.bump_once_guards("600000.SH", "FILL", {})
    assert fake.positions["600000.SH"]["fill_count"] == 1
    ps.bump_once_guards("600000.SH", "ADD", {})
    assert fake.positions["600000.SH"]["last_add_date"] == date.today()
    ps.bump_once_guards("600000.SH", "DCA", {"stage": 2})
    assert fake.positions["600000.SH"]["dca_count"] == 2, "dca_count 记的是档序不是次数"
    ps.bump_once_guards("600000.SH", "TRIM", {})          # 减持无一次性约束, 不该动任何列
    assert fake.positions["600000.SH"]["fill_count"] == 1

    # 当日已被规则闸拒过的 (代码, 动作) 要进 skip —— 否则超上限时每分钟重评一次、
    # 每分钟往评审账本灌一行, 判分锚被自己的噪声埋掉
    pms_repo.insert_ledger(ts_code="600000.SH", action="DCA", arbiter="rule",
                           verdict="REJECT", price_at=10.0, reason="超总仓上限")
    pms_repo.insert_ledger(ts_code="000001.SZ", action="ADD", arbiter="judge",
                           verdict="REJECT", price_at=10.0, reason="研判驳回")
    keys = ps._rejected_today_keys()
    assert ("600000.SH", "DCA") in keys, keys
    assert ("000001.SZ", "ADD") not in keys, "研判驳回不该进当日去重 —— 研判结论会变"


@case("账本服务·对账补仓位取下游成本价, 不拿现价充数")
def _():
    from app.core import recon as rc
    from app.repo import downstream_repo
    from app.services import ledger_service as ls, market
    fake = install_fakes(prices={"600000.SH": 10.0, "000001.SZ": 30.0})
    # 下游有两只票: A 真实成本 20 (现价 10, 实亏 50%), B 下游没给成本价
    downstream_repo.fetch_positions = lambda: {
        "columns": {"qty": "total_quantity", "cost": "cost_price"}, "raw_count": 2,
        "rows": [{"ts_code": "600000.SH", "qty": 1000, "avail_qty": 1000, "cost": 20.0,
                  "price": 10.0},
                 {"ts_code": "000001.SZ", "qty": 500, "avail_qty": 500, "cost": None,
                  "price": 30.0}]}
    r = ls.reconcile(apply_fix=True)
    assert r["ok"], r
    by = {f["ts_code"]: f for f in r["fixes"]}
    # A: 必须记 20 而不是 10 —— 记成 10 的话安全垫是 0, 这只实亏 50% 的票就永远不进补仓评估
    assert by["600000.SH"]["price"] == 20.0, by["600000.SH"]
    assert by["600000.SH"]["price_source"] == "下游成本价"
    # B: 下游没成本价才退到现价, 且留痕注明是估的
    assert by["000001.SZ"]["price"] == 30.0 and "兜底" in by["000001.SZ"]["price_source"]
    # 落到账本上: 摊薄成本 = 真实成本, 安全垫 = (10-20)/20 = -50%
    pos = fake.positions["600000.SH"]
    assert abs(float(pos["avg_cost"]) - 20.0) < 1e-6, pos
    assert abs(float(pos["cushion_pct"]) - (-0.5)) < 1e-4, pos

    # 纯逻辑侧: cost_map 缺项时逐只独立回退, 不会一只没成本就全退现价
    fixes = rc.build_recon_fixes(
        [{"ts_code": "A", "delta": 100}, {"ts_code": "B", "delta": 200}],
        price_map={"A": 1.0, "B": 2.0}, cost_map={"A": 9.0})
    assert [f["price"] for f in fixes] == [9.0, 2.0], fixes


@case("账本服务·对账以下游为准 + 连续不一致升级")
def _():
    from app.services import ledger_service as ls, param_store
    from app.repo import downstream_repo
    fake = install_fakes(prices={"600000.SH": 10.0})
    fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=1000, open_price=10.0,
                    open_date="2026-07-01")
    fake.update_position("600000.SH", total_qty=1000, avail_qty=1000)
    orig = downstream_repo.fetch_positions
    try:
        downstream_repo.fetch_positions = lambda: {
            "rows": [{"ts_code": "600000.SH", "qty": 1500, "avail_qty": 1500}],
            "columns": {"code": "stock_code", "qty": "current_qty"}, "raw_count": 1}
        r = ls.reconcile()
        assert r["ok"] and len(r["diffs"]) == 1 and r["diffs"][0]["delta"] == 500, r
        assert fake.positions["600000.SH"]["total_qty"] == 1500          # 以下游为准
        assert any(l["lot_type"] == "RECON" for l in fake.lots)          # 修正留痕
        assert any(x.get("action") == "RECON" for x in fake.ledger)
        assert r["severity"] == "WARN"
        assert r["streak"] == 1, r

        # 每跑一趟对账下游都比账本多 100 股 —— 这样每一趟都真的"不一致", 隔离出
        # 「同一天多趟到底加不加」这一个变量
        nxt = [1500]

        def _drift():
            nxt[0] += 100
            return {"rows": [{"ts_code": "600000.SH", "qty": nxt[0]}],
                    "columns": {"qty": "current_qty"}, "raw_count": 1}
        downstream_repo.fetch_positions = _drift

        # 同一交易日内再对账多少趟, 连续天数都**不动** —— 盘中轻对账每分钟跑一次,
        # 按次累加的话"连续 3 日"三分钟就到了, 日报还会写出"连续 175 日"这种数。
        for _ in range(3):
            r = ls.reconcile()
        assert r["diffs"] and r["streak"] == 1 and r["severity"] == "WARN", r

        # 跨交易日才推进: 把"上次推进日"往前拨, 等价于隔了一天再跑。
        # 必须走 set_param (不能直接改 fake.params) —— 一是要过 ParamStore 的缓存失效,
        # 二是**顺带守住白名单**: STREAK_YMD 键当初就是漏在白名单外, set_param 静默拒写、
        # prev_ymd 永远读回 0, 按日推进形同虚设。这里 ok 断言就是那道锁。
        for _i in range(2):
            w = param_store.set_param(ls.STREAK_YMD_KEY, 20260701 + _i, "test")
            assert w["ok"], w
            r = ls.reconcile()
        assert r["streak"] == 3, r                                      # 连续第 3 日
        assert r["severity"] == "ERROR", r
    finally:
        downstream_repo.fetch_positions = orig


@case("账本服务·下游读空绝不清账 (2026-07-29 实际发生过的 22 只全核销)")
def _():
    from app.services import ledger_service as ls
    from app.repo import downstream_repo
    fake = install_fakes(prices={f"6000{i:02d}.SH": 10.0 for i in range(22)})
    for i in range(22):
        code = f"6000{i:02d}.SH"
        fake.insert_lot(ts_code=code, lot_type="BASE", qty=1000, open_price=10.0,
                        open_date="2026-07-01")
        fake.update_position(code, total_qty=1000, avail_qty=1000)
    orig = downstream_repo.fetch_positions
    try:
        # 对端模拟环境重启, 下游持仓表读回空集。「一夜清仓」与「读空」在数据上完全同形,
        # 但代价不对称: 拦错了只是晚一天修账, 放错了是 22 个持仓的批次结构不可逆地没了。
        downstream_repo.fetch_positions = lambda: {
            "rows": [], "columns": {"code": "stock_code", "qty": "current_qty"},
            "raw_count": 0}
        r = ls.reconcile()
        assert r.get("blocked"), "下游读空必须拦截, 不能照着清账"
        assert r["severity"] == "ERROR", r["severity"]
        assert not r["fixes"], "拦截时不得产生任何修正"
        for i in range(22):
            assert fake.positions[f"6000{i:02d}.SH"]["total_qty"] == 1000, "持仓不得被动过"
        r2 = ls.reconcile(force=True)          # 人工确认下游读数无误后放行
        assert not r2.get("blocked"), "force 应绕过限制"
    finally:
        downstream_repo.fetch_positions = orig


@case("账本服务·日常小幅漂移仍照常自动修正 (不被爆炸半径误伤)")
def _():
    from app.services import ledger_service as ls
    from app.repo import downstream_repo
    fake = install_fakes(prices={f"6000{i:02d}.SH": 10.0 for i in range(22)})
    for i in range(22):
        code = f"6000{i:02d}.SH"
        fake.insert_lot(ts_code=code, lot_type="BASE", qty=1000, open_price=10.0,
                        open_date="2026-07-01")
        fake.update_position(code, total_qty=1000, avail_qty=1000)
    orig = downstream_repo.fetch_positions
    try:
        rows = [{"ts_code": f"6000{i:02d}.SH", "qty": 1000} for i in range(22)]
        rows[0]["qty"] = 1500                       # 22 只里只有 1 只对不上
        downstream_repo.fetch_positions = lambda: {
            "rows": rows, "columns": {"qty": "current_qty"}, "raw_count": 22}
        r = ls.reconcile()
        assert not r.get("blocked"), "小幅漂移不该被拦 —— 那正是对账的价值"
        assert fake.positions["600000.SH"]["total_qty"] == 1500, "以下游为准"
    finally:
        downstream_repo.fetch_positions = orig


@case("账本服务·日报生成 (关注区 + 次日除权检测快照)")
def _():
    from app.services import ledger_service as ls
    fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
                         positions=[{"ts_code": "600000.SH", "total_qty": 6000,
                                     "avg_cost": 9.0}])
    rep = ls.build_daily_report()
    assert rep["totals"]["portfolio_mv"] == 60_000.0
    assert rep["snapshot"]["600000.SH"] == {"qty": 6000, "price": 10.0}
    assert any(a["type"] == "行业约束停用" for a in rep["attention"]), rep["attention"]
    assert fake.reports[rep["ymd"]]["ymd"] == rep["ymd"]


@case("账本服务·除权检测走通 (10送10 → 批次按比例调整)")
def _():
    from app.services import ledger_service as ls
    fake = install_fakes(prices={"600000.SH": 5.0}, params={"PMS_TOTAL_SCALE": "2000000"},
                         positions=[{"ts_code": "600000.SH", "total_qty": 2000,
                                     "avg_cost": 10.0}])
    fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=2000, open_price=10.0,
                    open_date="2026-07-01")
    fake.upsert_report(20260726, {"snapshot": {"600000.SH": {"qty": 1000, "price": 10.0}}})
    r = ls.detect_and_apply_ex_right()
    assert r["ex_rights"] and abs(r["ex_rights"][0]["ratio"] - 2.0) < 1e-6, r
    lot = fake.lots[0]
    assert lot["qty"] == 4000 and abs(lot["open_price"] - 5.0) < 1e-6, lot


@case("盘前准备·T+1 可卖重置")
def _():
    from app.services import ledger_service as ls
    fake = install_fakes(prices={"600000.SH": 10.0},
                         positions=[{"ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 0,
                                     "avg_cost": 9.0}])
    r = ls.premarket()
    assert r["avail_reset"] >= 1, r
    assert fake.positions["600000.SH"]["avail_qty"] == 6000


@case("执行器·方案转指令 (GATED 批不转, HALT 直接完结)")
def _():
    from app.services import command_service as csvc, executor
    fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
                         positions=[{"ts_code": "600000.SH", "total_qty": 14000,
                                     "base_qty": 7000, "fill_qty": 3500, "add_qty": 3500,
                                     "avail_qty": 14000, "avg_cost": 9.0}])
    r = csvc.issue("REDUCE_EXPOSURE", {"pct": "3%"})
    assert r["ok"], r
    # 掺一条 GATED 方案 (建仓补足批) 与一条 HALT 方案
    fake.insert_plans([
        {"plan_id": "P_G", "command_id": r["command_id"], "ts_code": "600000.SH",
         "action": "FILL", "qty": 3000, "amount": 30000, "priority": 20,
         "deadline": "2026-07-30", "status": "GATED", "reason": "回踩补足批"},
        {"plan_id": "P_H", "command_id": r["command_id"], "ts_code": "600000.SH",
         "action": "HALT", "qty": 0, "amount": 0, "priority": 10,
         "deadline": "2026-07-30", "status": "PENDING", "reason": "撤在途买入"}])
    m = executor.materialize_plans()
    assert m["created"], m
    codes = {fake.instructions[i]["ts_code"] for i in m["created"]}
    assert codes == {"600000.SH"}, codes
    assert all(fake.instructions[i]["status"] == "PROPOSED" for i in m["created"])
    gated = [p for p in fake.plans if p["plan_id"] == "P_G"][0]
    assert gated["status"] == "GATED", gated              # 未解锁的批次不转指令
    halt = [p for p in fake.plans if p["plan_id"] == "P_H"][0]
    assert halt["status"] == "DONE", halt
    assert any("HALT" in s["why"] for s in m["skipped"]), m["skipped"]
    # 幂等: 再跑一次不重复建指令
    n1 = len(fake.instructions)
    executor.materialize_plans()
    assert len(fake.instructions) == n1


@case("执行器·出手一跳 (影子模式): 择时→规则闸→下发→子单→评审留痕")
def _():
    from datetime import datetime as _dt
    from app.services import executor
    fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
                         positions=[{"ts_code": "600000.SH", "total_qty": 6000,
                                     "avail_qty": 6000, "avg_cost": 9.0}])
    fake.insert_instruction(instruction_id="INS_S", origin_type="plan", origin_id="P1",
                            ts_code="600000.SH", action="TRIM", side="sell", qty=3000,
                            window_tdays=3, status="PROPOSED",
                            progress={"deadline": "2026-07-29", "is_command": True,
                                      "children": []})
    # 周一 10:05, 距截止 3 个交易日 → 当日配额 1000
    r = executor.run_tick(now=_dt(2026, 7, 27, 10, 5))
    assert r["ok"] and len(r["fired"]) == 1, r
    fired = r["fired"][0]
    assert fired["qty"] == 1000 and fired["mode"] == "shadow", fired
    ins = fake.instructions["INS_S"]
    assert ins["status"] == "DISPATCHED"
    assert len(ins["progress"]["children"]) == 1
    assert ins["progress"]["children"][0]["qty"] == 1000
    assert ins["progress"]["dispatched_at"]
    assert any(x["verdict"] == "PASS" and x["arbiter"] == "rule" for x in fake.ledger)
    # 同一天再跳一次: 配额已出完, 不重复下发
    r2 = executor.run_tick(now=_dt(2026, 7, 27, 10, 6))
    assert not r2["fired"] and r2["waited"], r2
    assert len(fake.instructions["INS_S"]["progress"]["children"]) == 1


@case("执行器·规则闸拦截时不下发且落拒绝留痕")
def _():
    from datetime import datetime as _dt
    from app.services import executor
    fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
                         positions=[{"ts_code": "600000.SH", "total_qty": 6000,
                                     "avail_qty": 6000, "avg_cost": 9.0}])
    fake.insert_instruction(instruction_id="INS_B", origin_type="plan", origin_id="P2",
                            ts_code="600000.SH", action="OPEN", side="buy", qty=1000,
                            window_tdays=1, status="PROPOSED",
                            progress={"deadline": "2026-07-27", "is_command": True,
                                      "children": []})
    from app.services import param_store
    param_store.set_param("PMS_GLOBAL_BUY_HALT", True, "test")
    r = executor.run_tick(now=_dt(2026, 7, 27, 10, 5))
    assert not r["fired"] and r["rejected"], r
    assert any("BUY_HALT" in f for f in r["rejected"][0]["failed"]), r["rejected"]
    assert fake.instructions["INS_B"]["status"] == "PROPOSED"       # 未下发
    assert any(x["verdict"] == "REJECT" for x in fake.ledger)


@case("执行器·试算模式只算不发不落库")
def _():
    from datetime import datetime as _dt
    from app.services import executor
    fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
                         positions=[{"ts_code": "600000.SH", "total_qty": 6000,
                                     "avail_qty": 6000, "avg_cost": 9.0}])
    fake.insert_instruction(instruction_id="INS_D", origin_type="plan", origin_id="P3",
                            ts_code="600000.SH", action="EXIT", side="sell", qty=2000,
                            window_tdays=1, status="PROPOSED",
                            progress={"deadline": "2026-07-27", "children": []})
    r = executor.run_tick(now=_dt(2026, 7, 27, 10, 5), dry_run=True)
    assert r["fired"] and r["fired"][0]["dry_run"] is True, r
    assert r["fired"][0]["qty"] == 2000                      # 末日全出
    assert fake.instructions["INS_D"]["status"] == "PROPOSED"
    assert not fake.instructions["INS_D"]["progress"]["children"]
    assert not fake.ledger


@case("执行器·窗口收口: 成交回写方案 + 足额置确认 + 命令类部分完成")
def _():
    from datetime import datetime as _dt
    from app.services import executor
    fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
                         positions=[{"ts_code": "600000.SH", "total_qty": 6000,
                                     "avail_qty": 6000, "avg_cost": 9.0}])
    fake.insert_plans([{"plan_id": "P_F", "command_id": "CMD_X", "ts_code": "600000.SH",
                        "action": "TRIM", "qty": 2000, "amount": 20000, "priority": 30,
                        "deadline": "2026-07-27", "status": "EXECUTING", "reason": "收利润"}])
    fake.insert_instruction(instruction_id="INS_F", origin_type="plan", origin_id="P_F",
                            ts_code="600000.SH", action="TRIM", side="sell", qty=2000,
                            status="DISPATCHED",
                            progress={"deadline": "2026-07-27", "is_command": True,
                                      "children": []})
    # exec_qty 不是 insert_instruction 的形参 (真 repo 没有), 造数据直接改桩里的字段
    fake.instructions["INS_F"]["exec_qty"] = 2000
    fake.insert_instruction(instruction_id="INS_P", origin_type="plan", origin_id="P_F",
                            ts_code="600000.SH", action="TRIM", side="sell", qty=2000,
                            status="DISPATCHED",
                            progress={"deadline": "2026-07-20", "is_command": True,
                                      "children": []})
    fake.instructions["INS_P"]["exec_qty"] = 500
    fake.insert_instruction(instruction_id="INS_A2", origin_type="proposal", origin_id="PR1",
                            ts_code="600000.SH", action="ADD", side="buy", qty=1000,
                            status="DISPATCHED",
                            progress={"deadline": "2026-07-20", "is_command": False,
                                      "children": []})
    r = executor.sweep_windows(now=_dt(2026, 7, 27, 15, 10))
    assert fake.instructions["INS_F"]["status"] == "CONFIRMED", fake.instructions["INS_F"]
    assert [p for p in fake.plans if p["plan_id"] == "P_F"][0]["filled_qty"] == 500
    assert any(x["instruction_id"] == "INS_P" for x in r["partial"]), r
    assert fake.instructions["INS_P"]["progress"]["window_verdict"]["verdict"] == "PARTIAL"
    assert "INS_A2" in r["expired"], r                    # 自主类窗口耗尽即作废
    assert fake.instructions["INS_A2"]["status"] == "EXPIRED"


@case("组合快照·真钱取 ws 资金快照(含当日回笼); 取不到则标明是估的")
def _():
    from app.services import portfolio
    fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"})
    t = portfolio.positions_view()["totals"]
    assert t["cash_source"] == "estimate" and t["cash_avail"] is None, t
    assert t["cash_est"] == 2_000_000.0, t          # scale − 市值, 是个估算值

    fake.qmt.put_snapshot(kind="funds", data={"total_asset": 981448.56,
                                              "available_cash": 971251.56,
                                              "sell_return_today": 48900.0})
    v = portfolio.positions_view()
    t = v["totals"]
    assert t["cash_source"] == "ws", t
    # 当日卖出回笼 T+0 可用, 必须算进去 —— 不算就把"卖一只买另一只"判成资金不足
    assert t["cash_avail"] == round(971251.56 + 48900.0, 2), t
    assert t["total_asset"] == 981448.56, t
    # 这一行是整件事的由来: 按 scale 估出来 200 万, 账户真钱只有 102 万, 差着一倍
    assert t["cash_est"] > t["cash_avail"], (t["cash_est"], t["cash_avail"])
    # caps_ctx 要把真钱与来源一起带给规则闸, 否则闸门只能降级
    caps = portfolio.caps_ctx(v, ts_code="600000.SH")
    assert caps["cash_source"] == "ws" and caps["cash_avail"] == t["cash_avail"], caps


@case("对账事实源·ws 快照新鲜 → 以 ws 为准建账 (表空集不再当事实)")
def _():
    from app.services import ledger_service as ls
    fake = install_fakes(prices={"600000.SH": 10.0})
    fake.qmt.put_snapshot(items=[{"ts_code": "600000.SH", "total_qty": 1100,
                                  "avail_qty": 1100, "cost_price": 9.273}])
    src = ls.positions_source()
    assert src["source"] == ls.SRC_WS, src
    assert src["rows"][0]["qty"] == 1100 and src["rows"][0]["cost"] == 9.273, src["rows"]
    assert not src["alerts"], src["alerts"]      # 表是空集, 不算"不一致"
    # 账本空 + ws 有持仓 = 首次建账, 爆炸半径闸放行, 成本价取下游 cost_price
    r = ls.reconcile(apply_fix=True)
    assert r["source"] == "ws" and not r.get("blocked"), r
    assert fake.positions["600000.SH"]["total_qty"] == 1100, fake.positions
    assert abs(float(fake.positions["600000.SH"]["avg_cost"]) - 9.273) < 1e-6, \
        "开仓价必须取下游 cost_price —— 拿现价当成本会让安全垫齐刷刷是 0"


@case("对账事实源·前缀式代码归一成点式 (不归一会造出一轮双向全量重写)")
def _():
    from app.services import ledger_service as ls
    fake = install_fakes(prices={"600000.SH": 10.0})
    fake.qmt.put_snapshot(items=[{"stock_code": "SH600000", "total_quantity": 600,
                                  "cost_price": 10.0}])
    src = ls.positions_source()
    assert src["rows"][0]["ts_code"] == "600000.SH", src["rows"]


@case("对账事实源·ws 与表对不上 → 用 ws 但必须留痕告警")
def _():
    from app.repo import downstream_repo
    from app.services import ledger_service as ls
    fake = install_fakes(prices={"600000.SH": 10.0})
    fake.qmt.put_snapshot(items=[{"ts_code": "600000.SH", "total_qty": 1100}])
    downstream_repo.fetch_positions = lambda: {
        "rows": [{"ts_code": "600000.SH", "qty": 700, "avail_qty": 700, "cost": 9.0,
                  "frozen": 0, "price": 10.0}],
        "columns": {"qty": "total_quantity"}, "raw_count": 1}
    src = ls.positions_source()
    assert src["source"] == ls.SRC_WS, src["source"]
    assert src["rows"][0]["qty"] == 1100, "以 ws 为准"
    codes = [a["code"] for a in src["alerts"]]
    assert "SOURCE_DISAGREE" in codes, src["alerts"]


@case("对账事实源·ws 快照过期 → 退回表并说明退回原因")
def _():
    from app.repo import downstream_repo
    from app.services import ledger_service as ls
    fake = install_fakes(prices={"600000.SH": 10.0},
                         params={"PMS_RECON_WS_SNAPSHOT_MAX_AGE_SEC": "900"})
    fake.qmt.put_snapshot(items=[{"ts_code": "600000.SH", "total_qty": 1100}], age_sec=5000)
    downstream_repo.fetch_positions = lambda: {
        "rows": [{"ts_code": "600000.SH", "qty": 700, "avail_qty": 700, "cost": 9.0,
                  "frozen": 0, "price": 10.0}],
        "columns": {"qty": "total_quantity"}, "raw_count": 1}
    src = ls.positions_source()
    assert src["source"] == ls.SRC_TABLE, src["source"]
    assert src["rows"][0]["qty"] == 700
    msg = " ".join(a["message"] for a in src["alerts"])
    assert "过期" in msg, src["alerts"]


@case("对账事实源·表应答空集仍算有效应答 → 归 table (空集是数据, 不是缺数据)")
def _():
    from app.services import ledger_service as ls
    install_fakes(prices={"600000.SH": 10.0})    # 默认桩: fetch_positions 成功返回 rows=[]
    src = ls.positions_source()
    # 这一条守着 force 的生路: 归 table 才会走到爆炸半径闸, 人工确认后 force 能放行;
    # 若把"应答了空"误判成 source=none, 对账入口就把 force 挡死了 (曾经真挡死过)
    assert src["source"] == ls.SRC_TABLE, src
    assert src["rows"] == [], src


@case("对账事实源·两个源都**无应答** + 本端有持仓 → 入口就拒, **force 也不放行**")
def _():
    from app.repo import downstream_repo
    from app.services import ledger_service as ls
    fake = install_fakes(prices={"600000.SH": 10.0})
    fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=1000, open_price=10.0,
                    open_date="2026-07-01")
    fake.update_position("600000.SH", total_qty=1000, avail_qty=1000)

    def boom():
        raise RuntimeError("Can't connect to MySQL server on '192.168.16.153'")
    downstream_repo.fetch_positions = boom       # 没塞快照 + 表查询异常 = 谁都没应答
    src = ls.positions_source()
    assert src["source"] == ls.SRC_NONE, src
    # force 的语义是「人工已确认下游读数正确」, 而这里根本没有读数可供确认 —— 没有任何数字,
    # 人也无从确认。真要清账走 reset_ledger.py, 别拿空壳子当"下游事实"去核销批次。
    for force in (False, True):
        r = ls.reconcile(apply_fix=True, force=force)
        assert r.get("blocked"), f"force={force} 也必须拒绝对账"
        assert r["severity"] == "ERROR" and not r["fixes"], r
    assert fake.positions["600000.SH"]["total_qty"] == 1000, "持仓一股都不许被动"


@case("对账事实源·两个源都无应答 + 账本也空 → 不报 ERROR (刚清账等对端装持仓)")
def _():
    from app.repo import downstream_repo
    from app.services import ledger_service as ls
    install_fakes()

    def boom():
        raise RuntimeError("库不可达")
    downstream_repo.fetch_positions = boom
    r = ls.reconcile(apply_fix=True)
    # 盘中轻对账每分钟一跳, 这个状态下反复刷 ERROR 只会把真告警埋掉
    assert not r.get("blocked") and r["severity"] == "OK", r
    assert "无可对之账" in (r.get("note") or ""), r


@case("ws 入账·三分流: 真单入账 / 联调单跳过 / **孤儿成交挂起不入账**")
def _():
    from app.repo import qmt_repo
    from app.services import ledger_service as ls
    fake = install_fakes(prices={"600000.SH": 10.0})
    fake.insert_instruction(instruction_id="INS_R", origin_type="plan", origin_id="P1",
                            ts_code="600000.SH", action="OPEN", side="buy", qty=100,
                            status="DISPATCHED")
    # 真单: 出口表有行、父指令不带 SMOKE_
    fake.qmt.enqueue_order(instruction_id="INS_R-1", parent_id="INS_R", ts_code="600000.SH",
                           side="buy", qty=100, limit_price=10.0, valid_until=0)
    fake.qmt.put_trade(seq=1, instruction_id="INS_R-1", ts_code="600000.SH", side="buy",
                       qty=100, price=10.0, fee=5.0)
    # 联调单: 出口表有行, 父指令带 SMOKE_
    fake.qmt.enqueue_order(instruction_id="INS_S-1", parent_id="SMOKE_INS_S",
                           ts_code="600000.SH", side="buy", qty=100, limit_price=9.0,
                           valid_until=0)
    fake.qmt.put_trade(seq=2, instruction_id="INS_S-1", ts_code="600000.SH", side="buy",
                       qty=100, price=9.0)
    # 孤儿: 出口表里根本没有这个 instruction_id (上下游停机后遗留的旧 inbox 行)
    fake.qmt.put_trade(seq=3, instruction_id="INS_GHOST-1", ts_code="600183.SH", side="buy",
                       qty=100, price=88.0)
    r = ls.consume_ws_trades()
    assert r["trades"] == 1, f"只有真单该进入账流程: {r}"
    assert r.get("smoke_skipped") == 1 and r.get("orphan_held") == 1, r
    assert fake.qmt.inbox[1]["processed"] == qmt_repo.PROC_BOOKED, fake.qmt.inbox[1]
    assert fake.qmt.inbox[2]["processed"] == qmt_repo.PROC_DIGESTED, fake.qmt.inbox[2]
    # 关键: 孤儿标 3 (挂起) 而不是 2 (已消化) —— 2 的语义是"确认过不用管", 混了就分不出漏账
    assert fake.qmt.inbox[3]["processed"] == qmt_repo.PROC_ORPHAN, fake.qmt.inbox[3]
    # 账本只认那一笔真单; 孤儿的标的一股都不许长出来
    assert [l["ts_code"] for l in fake.lots] == ["600000.SH"], fake.lots
    assert fake.positions["600000.SH"]["total_qty"] == 100
    assert "600183.SH" not in fake.positions or \
           fake.positions["600183.SH"]["total_qty"] == 0, fake.positions
    # 告警必须活着传出来 —— mapped["alerts"] 曾经直接赋值把它冲掉过
    assert any(a.get("type") == "ORPHAN_WS_TRADE" for a in r["alerts"]), r["alerts"]
    assert qmt_repo.inbox_orphan_count() == 1
    # 费用只出现金流水、不进成本 (§5.5): 真单那 5 元记一条 FEE, 摊薄成本仍是 10.0
    assert r["fees"] == 1 and len(fake.cash_flows) == 1, fake.cash_flows
    assert fake.cash_flows[0]["kind"] == "FEE" and fake.cash_flows[0]["amount"] == -5.0
    assert abs(float(fake.positions["600000.SH"]["avg_cost"]) - 10.0) < 1e-6, fake.positions


@case("ws 入账·出口表反查报错时一笔都不判 (留在待入账, 下一跳重来)")
def _():
    from app.repo import qmt_repo
    from app.services import ledger_service as ls
    fake = install_fakes(prices={"600000.SH": 10.0})
    fake.qmt.put_trade(seq=9, instruction_id="INS_X-1", ts_code="600000.SH", side="buy",
                       qty=100, price=10.0)

    def boom(_iid):
        raise RuntimeError("Lost connection to MySQL server during query")
    qmt_repo.get_order = boom
    r = ls.consume_ws_trades()
    # 原先这里把异常当"查不到"→ 当真单入账: 一次连接超时就能凭空造出一笔外部成交。
    assert not r["ok"] and r["errors"], r
    assert r["trades"] == 0 and not r.get("orphan_held"), r
    assert fake.qmt.inbox[9]["processed"] == qmt_repo.PROC_PENDING, fake.qmt.inbox[9]
    assert not fake.lots, fake.lots


@case("ws 入账·孤儿成交在通道状态里露出数字 (挂起的账不能没人报)")
def _():
    from app.services import dispatcher
    fake = install_fakes()
    assert dispatcher.channel_status()["orphan_held"] == 0
    fake.qmt.put_trade(seq=5, instruction_id="INS_GHOST-2", ts_code="600000.SH", side="sell",
                       qty=100, price=10.0, processed=3)
    assert dispatcher.channel_status()["orphan_held"] == 1


@case("下发通道·影子回执 / ws 出口队列 / 进程与连接两级降级 / 撤销走本地置状态")
def _():
    from datetime import datetime as _dtm
    from app.services import dispatcher, executor, param_store
    fake = install_fakes()
    assert dispatcher.mode() == "shadow"
    assert set(dispatcher.describe()["modes"]) == {"shadow", "ws"}, dispatcher.describe()
    d = dispatcher.dispatch(instruction_id="INS_1", ts_code="600000.SH", side="sell",
                            qty=1000, limit_price=9.98)
    assert d["ok"] and d["ref"] == "manual:INS_1" and "人工" in d["note"], d
    # 已作废的通道名不能再被设进来
    for dead in ("bad_mode", "plan_x", "channel_y"):
        assert param_store.set_param("PMS_DISPATCH_MODE", dead)["ok"] is False, dead
    assert param_store.set_param("PMS_DISPATCH_MODE", "ws")["ok"] is True
    vu = _dtm.now().replace(hour=14, minute=45, second=0, microsecond=0)

    def _send(iid, side="sell", qty=1000, px=9.98):
        return dispatcher.dispatch(instruction_id=iid, parent_id=iid.rsplit("_D", 1)[0],
                                   ts_code="600000.SH", side=side, qty=qty,
                                   limit_price=px, valid_until=vu, intent="TRIM")

    # ① ws 进程没在跑 (心跳陈旧): 买卖一律拒发 —— 排进队列也没人发, 装作成功更危险
    fake.qmt.set_channel("OFFLINE", alive=False)
    for side in ("sell", "buy"):
        r = _send(f"INS_2{side}_D01", side=side, qty=1000)
        assert r["ok"] is False and r["mode"] == "ws" and "未在线" in r["error"], r
    assert not fake.qmt.orders, "进程不在时不该往出口表里塞东西"

    # ② 进程在、连接断: 协议 §6.3 —— 停发增持, 减持照常入队等重连
    fake.qmt.set_channel("OFFLINE", alive=True)
    rb = _send("INS_3buy_D01", side="buy", qty=1000)
    assert rb["ok"] is False and "§6.3" in rb["error"], rb
    rs = _send("INS_3sell_D01", side="sell", qty=1000)
    assert rs["ok"] and "重连后" in rs["note"], rs
    assert fake.qmt.orders["INS_3sell_D01"]["status"] == "QUEUED"
    assert fake.qmt.orders["INS_3sell_D01"]["parent_id"] == "INS_3sell"

    # ③ 连接正常: 买入也放行, 落出口表即返回 (不等 QMT 的 ack)
    fake.qmt.set_channel("ONLINE", alive=True)
    rb2 = _send("INS_4buy_D01", side="buy", qty=1000, px=9.876)
    assert rb2["ok"] and rb2["ref"] == "INS_4buy_D01", rb2
    o = fake.qmt.orders["INS_4buy_D01"]
    assert o["limit_price"] == 9.88 and o["valid_until"] > 10 ** 12, o   # 2位小数 + 毫秒

    # ④ 不合协议的参数在本地就拦下, 不换对端一个 BAD_PARAM
    bad = _send("INS_5buy_D01", side="buy", qty=150)                     # 买入非整百
    assert bad["ok"] is False and "不合协议" in bad["error"], bad
    assert "INS_5buy_D01" not in fake.qmt.orders

    # ⑤ 撤单: 标记父指令名下所有在途子单, 由 ws 进程逐张发 cancel_order
    c = dispatcher.cancel(instruction_id="INS_3sell")
    assert c["ok"] and c["cancelled"] == 1 and c["cancel_id"].startswith("CXL-"), c
    assert fake.qmt.orders["INS_3sell_D01"]["cancel_state"] == "REQUESTED"
    assert dispatcher.cancel(instruction_id="INS_NOBODY")["cancelled"] == 0
    param_store.set_param("PMS_DISPATCH_MODE", "shadow")

    fake.insert_instruction(instruction_id="INS_C", origin_type="plan", origin_id="P1",
                            ts_code="600000.SH", action="TRIM", side="sell", qty=1000,
                            status="DISPATCHED", progress={"children": []})
    r = executor.cancel_instruction("INS_C")
    assert r["ok"] and fake.instructions["INS_C"]["status"] == "CANCELLED", r
    assert executor.cancel_instruction("INS_C")["ok"] is False


def _prop_fakes(**kw):
    """自主提议用的组合: A 票厚垫创新高(可加仓), B 票垫子回吐过半(可保垫减仓)。"""
    return install_fakes(
        prices={"600000.SH": 11.0, "000001.SZ": 10.4},
        high5={"600000.SH": 11.0, "000001.SZ": 12.0},     # B 没创新高, 只该出 TRIM
        params={"PMS_TOTAL_SCALE": "2000000", **(kw.get("params") or {})},
        positions=[{"ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 6000,
                    "base_qty": 6000, "avg_cost": 10.0, "cushion_peak": 0.0,
                    "target_pct": 0.06},
                   {"ts_code": "000001.SZ", "total_qty": 6000, "avail_qty": 6000,
                    "base_qty": 6000, "avg_cost": 10.0, "cushion_peak": 0.08,
                    "target_pct": 0.06}])


@case("自主提议·propose_only: 减持自动执行 / 增持入队 / 研判未接通即降级留痕")
def _():
    from app.services import proposal_service as ps
    fake = _prop_fakes(params={"PMS_AUTONOMY": "propose_only"})
    r = ps.scan_and_route()
    assert r["ok"], r
    ex = {(x["ts_code"], x["action"]) for x in r["executed"]}
    qd = {(x["ts_code"], x["action"]) for x in r["queued"]}
    assert ("000001.SZ", "TRIM") in ex, r          # 减持方向不设确认门槛
    assert ("600000.SH", "ADD") in qd, r           # 增持入队
    assert r["degraded"] is True, r                # 研判未接通 → 降级
    assert any("研判不可用" in x["why"] for x in r["queued"]), r["queued"]
    # 减持落了指令, 增持落了提议
    trim_ins = [i for i in fake.instructions.values() if i["action"] == "TRIM"]
    assert trim_ins and trim_ins[0]["side"] == "sell" and trim_ins[0]["qty"] == 2000, trim_ins
    add_prop = [p for p in fake.proposals.values() if p["action"] == "ADD"]
    assert add_prop and add_prop[0]["qty"] == 2700, add_prop
    assert add_prop[0]["hard_numbers"]["price"] == 11.0
    # 再扫一轮不重复提 (在途去重)。
    # 2026-08-06: 跳过原因从一句笼统的「已有在途提议/指令」改成分得开的四种 ——
    # 提议还是指令、单号是哪个、是当日被闸拒过 (日切重来) 还是在途 (明天照样挡)。
    # 这里同时钉住两条: 提议那条要报出提议号, 指令那条要报出指令号与状态。
    r2 = ps.scan_and_route()
    assert not r2["executed"] and not r2["queued"], r2
    whys = [s.get("why") or "" for s in r2["skipped"]]
    assert any("在途提议" in w and "PRP_" in w for w in whys), r2["skipped"]
    assert any("在途指令" in w and "INS_" in w for w in whys), r2["skipped"]


@case("自主提议·full + 研判通过: 增持直接落指令; 深档补仓仍强制确认")
def _():
    from app.services import judge, proposal_service as ps
    orig = judge.request
    try:
        judge.request = lambda c, context=None, **kw: {"verdict": judge.PASS,
                                                       "reason": "研判通过(桩)",
                                                       "degraded": False, "raw": None}
        fake = _prop_fakes(params={"PMS_AUTONOMY": "full"})
        r = ps.scan_and_route()
        assert r["degraded"] is False, r
        ex = {(x["ts_code"], x["action"]) for x in r["executed"]}
        assert ("600000.SH", "ADD") in ex and ("000001.SZ", "TRIM") in ex, r
        assert not r["queued"], r
        add_ins = [i for i in fake.instructions.values() if i["action"] == "ADD"]
        assert add_ins and add_ins[0]["side"] == "buy" and add_ins[0]["qty"] == 2700, add_ins
        assert add_ins[0]["progress"]["is_command"] is False

        # 深档补仓: 即使档位 full、研判通过, 也必须入队等用户点头
        fake2 = install_fakes(prices={"600519.SH": 8.4}, high5={"600519.SH": 9.9},
                              params={"PMS_TOTAL_SCALE": "2000000", "PMS_AUTONOMY": "full"},
                              positions=[{"ts_code": "600519.SH", "total_qty": 6000,
                                          "avail_qty": 6000, "base_qty": 6000,
                                          "avg_cost": 10.0, "cushion_peak": 0.0,
                                          "target_pct": 0.06}])
        r2 = ps.scan_and_route()
        qd = {(x["ts_code"], x["action"]) for x in r2["queued"]}
        assert ("600519.SH", "DCA") in qd, r2
        assert any("深档" in x["why"] for x in r2["queued"]), r2["queued"]
        prop = [p for p in fake2.proposals.values() if p["action"] == "DCA"][0]
        assert prop["qty"] == 3000 and prop["hard_numbers"]["stage"] == 2, prop
    finally:
        judge.request = orig


@case("自主提议·研判驳回与规则闸拦截各自留痕")
def _():
    from app.services import judge, proposal_service as ps
    orig = judge.request
    try:
        judge.request = lambda c, context=None, **kw: {"verdict": judge.REJECT,
                                                       "reason": "形态走坏, 不宜加仓",
                                                       "degraded": False, "raw": None}
        fake = _prop_fakes(params={"PMS_AUTONOMY": "full"})
        r = ps.scan_and_route()
        rej = {(x["ts_code"], x["action"], x["by"]) for x in r["rejected"]}
        assert ("600000.SH", "ADD", "judge") in rej, r
        assert any(x["arbiter"] == "judge" and x["verdict"] == "REJECT" for x in fake.ledger)
        assert not any(i["action"] == "ADD" for i in fake.instructions.values())
    finally:
        judge.request = orig

    # 规则闸拦截: 全局暂停买入
    fake2 = _prop_fakes(params={"PMS_AUTONOMY": "full", "PMS_GLOBAL_BUY_HALT": "true"})
    r2 = ps.scan_and_route()
    rej2 = {(x["ts_code"], x["action"], x["by"]) for x in r2["rejected"]}
    assert ("600000.SH", "ADD", "rule") in rej2, r2
    assert any("BUY_HALT" in f for x in r2["rejected"] for f in x["failed"]), r2
    assert any(x["arbiter"] == "rule" and x["verdict"] == "REJECT" for x in fake2.ledger)
    # 减持不受暂停买入影响, 照样执行
    assert ("000001.SZ", "TRIM") in {(x["ts_code"], x["action"]) for x in r2["executed"]}, r2


@case("自主提议·档位 off 与休假模式不扫描; 试算不落表")
def _():
    from app.services import proposal_service as ps
    _prop_fakes(params={"PMS_AUTONOMY": "off"})
    r = ps.scan_and_route()
    assert r["candidates"] == 0 and any("off" in s["why"] for s in r["skipped"]), r

    _prop_fakes(params={"PMS_AUTONOMY": "full", "PMS_GLOBAL_EXEC_HALT": "true"})
    r2 = ps.scan_and_route()
    assert any("休假" in s["why"] for s in r2["skipped"]), r2

    fake = _prop_fakes(params={"PMS_AUTONOMY": "propose_only"})
    r3 = ps.scan_and_route(dry_run=True)
    assert (r3["executed"] or r3["queued"]) and all(
        x.get("dry_run") for x in r3["executed"] + r3["queued"]), r3
    assert not fake.instructions and not fake.proposals and not fake.ledger


@case("研判闸·未配置即不可用, 动作不在研判范围则直接放行")
def _():
    from app.services import judge
    install_fakes()
    assert judge.available() is False
    st = judge.status()
    assert st["available"] is False and "未配置" in st["reason"], st
    r = judge.request({"action": "ADD", "ts_code": "600000.SH", "qty": 100})
    assert r["verdict"] == judge.UNAVAILABLE and r["degraded"] is True, r
    r2 = judge.request({"action": "TRIM", "ts_code": "600000.SH", "qty": 100})
    assert r2["verdict"] == judge.PASS and r2["degraded"] is False, r2   # TRIM 不在研判范围


class FakeRedis:
    """Redis Stream 的最小替身 (消费组 + xreadgroup + ack)。"""

    def __init__(self, msgs=None):
        self.msgs = dict(msgs or {})
        self.acked, self.groups = [], []

    def xgroup_create(self, key, group, id="$", mkstream=False):
        self.groups.append((key, group))

    def xreadgroup(self, group, consumer, streams, count=10, block=0):
        key = list(streams)[0]
        m = self.msgs.pop(key, [])
        return [(key, m)] if m else []

    def xack(self, key, group, msg_id):
        self.acked.append(msg_id)

    def xlen(self, key):
        return len(self.msgs.get(key, []))

    def xinfo_groups(self, key):
        return [{"name": g, "pending": 0} for k, g in self.groups if k == key]


def _install_signal_fakes(fake, sell_msgs=None, intraday_msgs=None):
    """把两条流的假客户端装上, 返回 {db: FakeRedis}。"""
    import json as _json
    from datetime import datetime as _dt
    from app.services import signal_service as ss
    from config.settings import settings as _st
    ymd = _dt.now().strftime("%Y-%m-%d")
    r2 = FakeRedis({f"intraday_signals:{ymd}": list(intraday_msgs or [])})
    r3 = FakeRedis({"bionic:signals:llm_sell_actions": list(sell_msgs or [])})
    by_db = {_st.SIGNAL_REDIS_DB_INTRADAY: r2, _st.SIGNAL_REDIS_DB_ACTIONS: r3}
    ss._client = lambda db: by_db[db]
    return by_db


def _sell_msg(mid, code, conf, reason="逻辑走坏"):
    import json as _json
    return (mid, {"data": _json.dumps({"ts_code": code, "action": "SELL",
                                       "confidence": conf, "llm_reason": reason},
                                      ensure_ascii=False)})


@case("信号消化·高置信风控卖出转清仓指令; 中等置信落提议; 当日去重")
def _():
    from app.services import signal_service as ss
    fake = install_fakes(prices={"600000.SH": 10.0, "000001.SZ": 8.0},
                         params={"PMS_TOTAL_SCALE": "2000000"},
                         positions=[{"ts_code": "600000.SH", "total_qty": 6000,
                                     "avail_qty": 6000, "avg_cost": 10.0},
                                    {"ts_code": "000001.SZ", "total_qty": 3000,
                                     "avail_qty": 3000, "avg_cost": 8.0}])
    by_db = _install_signal_fakes(fake, sell_msgs=[
        _sell_msg("3-1", "600000.SH", 92),      # 高置信 → 清仓
        _sell_msg("3-2", "000001.SZ", 80),      # 中置信 → 提议
        _sell_msg("3-3", "600519.SH", 95),      # 没持仓 → 忽略
    ])
    r = ss.consume()
    assert r["ok"], r
    assert [x["ts_code"] for x in r["exits"]] == ["600000.SH"], r
    assert r["exits"][0]["qty"] == 6000
    assert [x["ts_code"] for x in r["proposals"]] == ["000001.SZ"], r
    assert r["proposals"][0]["qty"] == 1000                  # 3000 的 1/3
    assert r["ignored"] >= 1, r
    assert len(by_db[3].acked) == 3, by_db[3].acked          # 三条都 ACK

    ins = [i for i in fake.instructions.values() if i["action"] == "EXIT"]
    assert ins and ins[0]["side"] == "sell" and ins[0]["progress"]["from_signal"] is True
    assert any(x["arbiter"] == "rule" and x["action"] == "EXIT" for x in fake.ledger)
    prop = [p for p in fake.proposals.values() if p["action"] == "TRIM"]
    assert prop and prop[0]["hard_numbers"]["signal_source"] == "risk_sell", prop

    # 同一条信号再来一次: 当日去重 + 在途检查, 不重复下指令
    n_ins = len(fake.instructions)
    _install_signal_fakes(fake, sell_msgs=[_sell_msg("3-4", "600000.SH", 92)])
    r2 = ss.consume()
    assert not r2["exits"] and len(fake.instructions) == n_ins, r2


@case("信号消化·BUY 只留痕不买; 关闭开关即不消化; 试算不落表不ACK")
def _():
    from app.services import signal_service as ss
    fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
                         positions=[{"ts_code": "600000.SH", "total_qty": 6000,
                                     "avail_qty": 6000, "avg_cost": 10.0}])
    _install_signal_fakes(fake, intraday_msgs=[
        ("1-1", {"ts_code": "600000.SH", "action": "BUY", "confidence": "0.95"})])
    r = ss.consume()
    # 2026-08-06: BUY 改走 NOTE_BUY —— 仍然一股都不买, 但账本必须留下一行, 而且
    # **未持仓的票也要留** (从前 RECORD 只给持仓票写, 没持仓的票一个字都没有, 事后无从复盘)。
    assert r.get("buy_notes") and not r["exits"], r
    assert not any(i["side"] == "buy" for i in fake.instructions.values()), "买入信号绝不下买单"
    notes = [x for x in fake.ledger if x["action"] == ss.SIGNAL_BUY_ACTION]
    assert notes and notes[0]["verdict"] == "NOTE", fake.ledger

    fake2 = install_fakes(prices={"600000.SH": 10.0},
                         params={"PMS_TOTAL_SCALE": "2000000", "PMS_SIGNAL_ENABLED": "false"},
                         positions=[{"ts_code": "600000.SH", "total_qty": 6000,
                                     "avail_qty": 6000, "avg_cost": 10.0}])
    by = _install_signal_fakes(fake2, sell_msgs=[_sell_msg("3-9", "600000.SH", 95)])
    r2 = ss.consume()
    assert "skipped" in r2 and not fake2.instructions, r2

    fake3 = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
                          positions=[{"ts_code": "600000.SH", "total_qty": 6000,
                                      "avail_qty": 6000, "avg_cost": 10.0}])
    by3 = _install_signal_fakes(fake3, sell_msgs=[_sell_msg("3-10", "600000.SH", 95)])
    r3 = ss.consume(dry_run=True)
    assert r3["exits"] and r3["exits"][0]["dry_run"] is True, r3
    assert not fake3.instructions and not fake3.ledger
    assert by3[3].acked == [], "试算不该 ACK"


@case("装配·执行相关路由与调度接线到位")
def _():
    from app.web.main import app
    from app import scheduler as sch
    paths = {r.path for r in app.routes}
    for p in ("/api/ops/materialize", "/api/ops/exec-tick", "/api/ops/sweep-windows",
              "/api/instructions/{instruction_id}/cancel", "/api/dispatch-mode",
              "/api/ops/scan-proposals", "/api/ops/digest-signals", "/api/signal-status"):
        assert p in paths, p
    import inspect
    src = inspect.getsource(sch.intraday_exec)
    assert "executor" in src and "run_tick" in src, "调度器未接执行器"
    assert "proposal_service" in src, "调度器未接自主提议扫描"
    assert "signal_service" in inspect.getsource(sch.signal_digest), "调度器未接信号消化"


# ---------------------------------------------------------------- runner
# ================================================================ 行业数据源实探
def _real_industry():
    """把被 install_fakes 打过桩的 industry 恢复成真实实现 (reload 就地替换属性)。"""
    import importlib
    from app.services import industry as ind
    importlib.reload(ind)
    ind.invalidate()
    return ind


def _real_downstream():
    """同上, 恢复 downstream_repo —— install_fakes 把 category_columns 也打了桩。"""
    import importlib
    from app.repo import downstream_repo as dr
    importlib.reload(dr)
    return dr


@case("行业源·gp_stock_category 查得到才算 ready (参数填了不等于数据源能用)")
def _():
    from app.repo import downstream_repo
    fake = install_fakes(params={"PMS_SECTOR_SOURCE": "gp_stock_category"})
    ind = _real_industry()
    orig = downstream_repo.probe_category
    try:
        downstream_repo.probe_category = lambda c: {
            "value": "电力设备", "code_col": "stock_code", "industry_col": "industry",
            "columns": ["stock_code", "industry"], "error": None}
        assert ind.ready() is True
        st = ind.status()
        assert st["ready"] is True and st["count"] == st["probe"]["tried"] > 0, st
        assert "命中" in st["hint"], st["hint"]
        assert ind.get("600000.SH") == "电力设备"
    finally:
        downstream_repo.probe_category = orig
    _ = fake


@case("行业源·一只都查不到 → ready=False, 约束按未配置停用 (绝不静默失效)")
def _():
    from app.repo import downstream_repo
    install_fakes(params={"PMS_SECTOR_SOURCE": "gp_stock_category"})
    ind = _real_industry()
    orig = downstream_repo.probe_category
    try:
        # ① 表里没有这些票 (无报错)
        downstream_repo.probe_category = lambda c: {
            "value": None, "code_col": None, "industry_col": None, "columns": None,
            "error": None}
        assert ind.ready() is False
        st = ind.status()
        assert st["ready"] is False and "一只都没查到" in st["hint"], st["hint"]
        assert "表里没有这些票" in st["hint"] and "导出下游表结构" in st["hint"], st["hint"]
        # ② 表根本查不了 (有报错) —— 错误必须冒到 hint 上, 不能吞
        ind.invalidate()
        downstream_repo.probe_category = lambda c: {
            "value": None, "code_col": None, "industry_col": None, "columns": None,
            "error": "stock_code: ProgrammingError: Table doesn't exist"}
        assert ind.ready() is False
        assert "Table doesn't exist" in ind.status()["hint"]
    finally:
        downstream_repo.probe_category = orig


@case("行业源·gp_hybk: 三级 884 / 每票只认 bk_code 最小的主行业 / 代码写法自适应")
def _():
    from app.repo import industry_repo as ir
    install_fakes(params={"PMS_SECTOR_SOURCE": "gp_hybk"})
    ind = _real_industry()
    import importlib
    importlib.reload(ir)
    orig = ir.fetch_all
    try:
        # 表里用前缀式 SH600000; 一票挂 二级×1 + 三级×2
        TBL = [
            {"gp_code": "SH600000", "bk_code": 881155, "bk_name": "银行(二级)"},
            {"gp_code": "SH600000", "bk_code": 884219, "bk_name": "股份制银行"},
            {"gp_code": "SH600000", "bk_code": 884101, "bk_name": "全国性银行"},
            {"gp_code": "SZ000001", "bk_code": 884219, "bk_name": "股份制银行"},
        ]
        calls = []

        def _fa(sql, params=None, source=None):
            assert source == "index", source          # 必须走 199, 不是 153 代理
            want = set((params or {}).values())
            calls.append(sorted(want))
            return [r for r in TBL if r["gp_code"] in want]
        ir.fetch_all = _fa
        p = ir.probe(force=True)
        assert p["form"] == "prefix", p               # 点式先试没命中, 自动落到前缀式
        m = ir.fetch_industries(["600000.SH", "000001.SZ"], level="l3")
        assert m["600000.SH"] == [("884101", "全国性银行"), ("884219", "股份制银行")], m
        # 主行业 = bk_code 升序第一个 (884101 < 884219), 二级 881 不能混进来
        pm = ir.primary_industry_map(["600000.SH", "000001.SZ"], level="l3")
        assert pm == {"600000.SH": "全国性银行", "000001.SZ": "股份制银行"}, pm
        assert ir.primary_industry_map(["600000.SH"], level="l2") == {"600000.SH": "银行(二级)"}
        # 服务层: ready 有证据, get_many 批量且按日缓存
        ind.invalidate()
        assert ind.ready() is True
        assert ind.get_many(["600000.SH", "000001.SZ"]) == {
            "600000.SH": "全国性银行", "000001.SZ": "股份制银行"}
        n = len(calls)
        ind.get_many(["600000.SH", "000001.SZ"])      # 二次不再打库
        assert len(calls) == n, calls[n:]
        assert ind.get("600000.SH") == "全国性银行"
        st = ind.status()
        assert st["ready"] is True and st["level"] == "l3" and "884" in st["hint"], st
    finally:
        ir.fetch_all = orig
        ir.invalidate()


@case("行业源·gp_hybk 三种写法都不命中 → ready=False, 错误说清是表能查还是查不了")
def _():
    from app.repo import industry_repo as ir
    install_fakes(params={"PMS_SECTOR_SOURCE": "gp_hybk"})
    ind = _real_industry()
    import importlib
    importlib.reload(ir)
    orig = ir.fetch_all
    try:
        ir.fetch_all = lambda sql, params=None, source=None: []      # 能查, 但没有这些票
        ind.invalidate()
        assert ind.ready() is False
        st = ind.status()
        assert st["ready"] is False and "用不了" in st["hint"], st["hint"]
        assert "三种代码写法都没命中" in st["hint"], st["hint"]
        assert "DB_MYSQL_URL" in st["hint"], st["hint"]     # 指到该改哪儿
        # 查不了的情况: 错误原文要冒上来
        ir.invalidate()

        def _boom(sql, params=None, source=None):
            raise RuntimeError("Table 'db_gp_cj.gp_hybk' doesn't exist")
        ir.fetch_all = _boom
        ind.invalidate()
        assert ind.ready() is False
        assert "doesn't exist" in ind.status()["hint"], ind.status()["hint"]
    finally:
        ir.fetch_all = orig
        ir.invalidate()


@case("行业源·先 SHOW COLUMNS 认列再查: 没有代码列时直接给可执行的报错")
def _():
    install_fakes(params={"PMS_SECTOR_SOURCE": "gp_stock_category"})
    ind = _real_industry()
    dr = _real_downstream()          # category_columns 也被 install_fakes 打过桩
    orig_fa, orig_fo = dr.fetch_all, dr.fetch_one
    try:
        # ① 实机遇到的情况: 表在, 但没有 stock_code 这类代码列
        dr._cat_cols.update({"at": 0.0, "data": None})
        dr.fetch_all = lambda sql, p=None: [{"Field": "gp_name"}, {"Field": "sw_l1"}]
        meta = dr.category_columns(force=True)
        assert meta["code_col"] is None and "没有可识别的代码列" in meta["error"], meta
        assert "gp_name" in meta["error"], meta["error"]      # 把实际列名报出来才可执行
        assert dr.probe_category("600000.SH")["value"] is None
        assert ind.ready() is False
        # ② 列认出来了: 代码写法逐个试, 命中即返回
        dr._cat_cols.update({"at": 0.0, "data": None})
        dr.fetch_all = lambda sql, p=None: [{"Field": "ts_code"}, {"Field": "sw_l1"}]
        seen = []

        def _one(sql, p=None):
            seen.append((p or {}).get("code"))
            return {"ts_code": "SH600000", "sw_l1": "银行"} if (p or {}).get("code") == "SH600000" else None
        dr.fetch_one = _one
        dr.category_columns(force=True)
        r = dr.probe_category("600000.SH")
        assert r["value"] == "银行" and r["industry_col"] == "sw_l1", r
        assert seen == ["600000.SH", "SH600000"], seen     # 点式先试, 再前缀式
        ind.invalidate()
        assert ind.ready() is True
    finally:
        dr.fetch_all, dr.fetch_one = orig_fa, orig_fo
        dr._cat_cols.update({"at": 0.0, "data": None})


@case("行业源·custom_table 映射表为空同样不算 ready (空表 = 全票 None = 约束失效)")
def _():
    fake = install_fakes(params={"PMS_SECTOR_SOURCE": "custom_table"})
    ind = _real_industry()
    assert ind.ready() is False
    assert "是空的" in ind.status()["hint"], ind.status()["hint"]
    fake.industry = {}
    ind.invalidate()
    from app.repo import pms_repo
    orig = pms_repo.list_industry
    try:
        pms_repo.list_industry = lambda limit=5000: [
            {"ts_code": "600000.SH", "industry": "银行", "updated_at": ""}]
        assert ind.ready() is True
        assert ind.get("600000.SH") == "银行"
    finally:
        pms_repo.list_industry = orig


# ================================================================ 候选池 (上游 /plan)
def _stub_sp(csvc, mapping):
    """替换 effective_stock_params, 返回 restore 闭包。"""
    orig = csvc.effective_stock_params
    csvc.effective_stock_params = lambda: dict(mapping)
    return lambda: setattr(csvc, "effective_stock_params", orig)


@case("候选池·plan_api 独占: 价格现取 / 无价剔除 / 黑名单与持仓剔除 / 白名单压过计划票")
def _():
    from app.services import command_service as csvc, plan_feed
    install_fakes(prices={"600418.SH": 12.5, "300952.SZ": 30.0, "600000.SH": 10.0,
                          "600104.SH": 18.0},
                  params={"PMS_TOTAL_SCALE": "980000", "PMS_CANDIDATE_SOURCE": "plan_api"})
    restore_sp = _stub_sp(csvc, {"600000.SH": {"white": True}, "600104.SH": {"black": True}})
    orig = plan_feed.candidates
    try:
        plan_feed.candidates = lambda **kw: {
            "date": "2026-07-29", "considered": 4, "eligible": 4, "dropped": {},
            "items": [{"ts_code": "600418.SH", "score": 242.24, "theme": "整车"},
                      {"ts_code": "688717.SH", "score": 242.05, "theme": "储能"},
                      {"ts_code": "300952.SZ", "score": 241.91, "theme": "传感器"},
                      {"ts_code": "600104.SH", "score": 241.18, "theme": "整车"}]}
        pool = csvc._candidates({"held": [{"ts_code": "605598.SH"}]})
    finally:
        plan_feed.candidates = orig
        restore_sp()
    by = {c["ts_code"]: c for c in pool}
    # 688717.SH 行情里没有价 → 必须剔除 (planner 对 price<=0 只是静默跳过, 那样少票看不出来)
    assert "688717.SH" not in by, by
    assert "600104.SH" not in by, "黑名单票不得进池"
    assert by["600418.SH"]["price"] == 12.5 and by["600418.SH"]["src"] == "plan_api"
    assert by["600418.SH"]["theme"] == "整车" and by["600418.SH"]["sector"] is None
    # 白名单是用户点名的票, 分必须压过任何计划票 —— planner 只认 score 一把尺子
    assert by["600000.SH"]["src"] == "whitelist"
    plan_max = max(c["score"] for c in pool if c["src"] == "plan_api")
    assert by["600000.SH"]["score"] > plan_max, (by["600000.SH"]["score"], plan_max)


@case("候选池·盘前无实时价回落昨收 (db13 存的是当日分钟线, 盘前那张 key 不存在)")
def _():
    from app.services import command_service as csvc, plan_feed
    install_fakes(prices={}, prev_close={"600418.SH": 12.5, "300952.SZ": 30.0},
                  params={"PMS_CANDIDATE_SOURCE": "plan_api"})
    restore_sp = _stub_sp(csvc, {})
    orig = plan_feed.candidates
    try:
        plan_feed.candidates = lambda **kw: {
            "date": "2026-07-30", "considered": 3, "eligible": 3, "dropped": {},
            "items": [{"ts_code": "600418.SH", "score": 242.24, "theme": "整车"},
                      {"ts_code": "300952.SZ", "score": 241.91, "theme": "传感器"},
                      {"ts_code": "688717.SH", "score": 242.05, "theme": "储能"}]}
        pool = csvc._candidates({"held": []})
    finally:
        plan_feed.candidates = orig
        restore_sp()
    by = {c["ts_code"]: c for c in pool}
    # 有昨收的进池并标明价格来源; 实时价与昨收都没有的才剔除
    assert set(by) == {"600418.SH", "300952.SZ"}, by
    assert by["600418.SH"]["price"] == 12.5
    assert by["600418.SH"]["price_source"] == "prev_close", by["600418.SH"]
    assert "688717.SH" not in by


@case("候选池·上游计划不可用: 池为空, 绝不静默回退旧 trading_buy_plan 表")
def _():
    from app.repo import downstream_repo
    from app.services import command_service as csvc, plan_feed
    install_fakes(prices={"601111.SH": 9.0}, params={"PMS_CANDIDATE_SOURCE": "plan_api"})
    restore_sp = _stub_sp(csvc, {})
    orig_c, orig_bp = plan_feed.candidates, downstream_repo.fetch_buy_plans

    def _boom(**kw):
        raise plan_feed.PlanFeedError("接口超时")
    try:
        plan_feed.candidates = _boom
        downstream_repo.fetch_buy_plans = lambda **kw: [
            {"ts_code": "601111.SH", "price": 9.0, "score": 0.8}]
        pool = csvc._candidates({"held": []})
    finally:
        plan_feed.candidates, downstream_repo.fetch_buy_plans = orig_c, orig_bp
        restore_sp()
    assert pool == [], f"上游拿不到时必须让候选池为空, 实际拿到 {pool}"


@case("候选池·both: 计划票与旧表票取并集, 同码只留计划那份")
def _():
    from app.repo import downstream_repo
    from app.services import command_service as csvc, plan_feed
    install_fakes(prices={"600418.SH": 12.5, "601111.SH": 9.0},
                  params={"PMS_CANDIDATE_SOURCE": "both"})
    restore_sp = _stub_sp(csvc, {})
    orig_c, orig_bp = plan_feed.candidates, downstream_repo.fetch_buy_plans
    try:
        plan_feed.candidates = lambda **kw: {
            "date": "2026-07-29", "considered": 1, "eligible": 1, "dropped": {},
            "items": [{"ts_code": "600418.SH", "score": 242.24, "theme": "整车"}]}
        downstream_repo.fetch_buy_plans = lambda **kw: [
            {"ts_code": "600418.SH", "price": 11.0, "score": 0.9},      # 同码, 计划优先
            {"ts_code": "601111.SH", "price": 9.0, "score": 0.8}]
        pool = csvc._candidates({"held": []})
    finally:
        plan_feed.candidates, downstream_repo.fetch_buy_plans = orig_c, orig_bp
        restore_sp()
    by = {c["ts_code"]: c for c in pool}
    assert set(by) == {"600418.SH", "601111.SH"}, by
    assert by["600418.SH"]["src"] == "plan_api" and by["600418.SH"]["price"] == 12.5
    assert by["601111.SH"]["src"] == "buy_plan" and by["601111.SH"]["price"] == 9.0


def main():
    # 静音日志: 本套里有好几条用例**故意**触发异常与告警来验证「守成」行为
    # (调度守卫吞异常、外部成交告警、连续对账升级 ERROR、窗口耗尽告警),
    # 这些 ERROR 栈打在测试输出里会被误读成失败。判定标准只看断言, 不看日志。
    import logging
    logging.disable(logging.CRITICAL)
    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()
