# -*- coding: utf-8 -*-
"""
第二批模块单测 (实机运行, 零外部依赖)
======================================
运行: 在 tradingSystem 仓库根目录执行  python scripts/test_batch2_units.py
覆盖: command_spec 命令目录/校验/状态机/冲突; planner 降仓凑额四档+升仓+建仓+个股方案;
      recon 回放认领/外部成交/对账差异/除权/T+1可用量。
约定同 test_core_units.py: 全过输出 "ALL PASS (n cases)" 退出码 0, 任一失败退出码 1。
"""
import os
import sys
import traceback

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

from app.core import command_spec as cs                                        # noqa: E402
from app.core import planner as pl                                             # noqa: E402
from app.core import recon as rc                                               # noqa: E402

RESULTS = []


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


# ================================================================ command_spec
@case("命令校验·降仓参数归一 (10% 字符串 → 0.1, 窗口取默认值)")
def _():
    p, e = cs.validate("REDUCE_EXPOSURE", {"pct": "10%"})
    assert e == [], e
    assert abs(p["pct"] - 0.10) < 1e-9 and p["window_tdays"] == 3, p
    p2, e2 = cs.validate("REDUCE_EXPOSURE", {"pct": 0.1, "window_tdays": 5})
    assert e2 == [] and p2["window_tdays"] == 5, (p2, e2)


@case("命令校验·缺参/越界/未知字段/未知命令全部拒绝")
def _():
    _, e1 = cs.validate("REDUCE_EXPOSURE", {})
    assert any(x.startswith("MISSING") for x in e1), e1
    _, e2 = cs.validate("SET_PORTFOLIO_CAP", {"cap": 1.5})
    assert any(x.startswith("OUT_OF_RANGE") for x in e2), e2
    _, e3 = cs.validate("SET_MAX_NAMES", {"n": 15, "foo": 1})
    assert any(x.startswith("UNKNOWN_FIELD") for x in e3), e3
    _, e4 = cs.validate("NOT_A_CMD", {})
    assert any(x.startswith("UNKNOWN_CMD") for x in e4), e4
    _, e5 = cs.validate("SET_AUTONOMY", {"mode": "auto"})
    assert any(x.startswith("BAD_ENUM") for x in e5), e5
    _, e6 = cs.validate("LIQUIDATE_ALL", {})          # 紧急清仓必须二次确认
    assert any(x.startswith("MISSING") for x in e6), e6


@case("命令校验·股票代码归一 (点式/前缀式/裸码)")
def _():
    n = cs.normalize_code
    assert n("600000.SH") == "600000.SH"
    assert n("sh600000") == "600000.SH"
    assert n("600000") == "600000.SH"
    assert n("000001") == "000001.SZ"
    assert n("300750") == "300750.SZ"
    assert n("430047") == "430047.BJ"
    p, e = cs.validate("EXIT_STOCK", {"ts_code": "sz000001"})
    assert e == [] and p["ts_code"] == "000001.SZ", (p, e)


@case("状态机·任务命令合法链路与非法迁移")
def _():
    st = cs.ST_PENDING
    for nxt in (cs.ST_PLANNING, cs.ST_EXECUTING, cs.ST_DONE):
        st = cs.transition(cs.CLS_TASK, st, nxt)
    assert st == cs.ST_DONE
    assert not cs.can_transition(cs.CLS_TASK, cs.ST_PENDING, cs.ST_DONE)
    assert not cs.can_transition(cs.CLS_TASK, cs.ST_DONE, cs.ST_EXECUTING)
    try:
        cs.transition(cs.CLS_TASK, cs.ST_DONE, cs.ST_EXECUTING)
        assert False, "非法迁移未拦截"
    except ValueError:
        pass
    # 部分完成可顺延回执行中
    assert cs.can_transition(cs.CLS_TASK, cs.ST_PARTIAL, cs.ST_EXECUTING)


@case("状态机·参数命令生效与被覆盖")
def _():
    assert cs.can_transition(cs.CLS_PARAM, cs.ST_PENDING, cs.ST_EFFECTIVE)
    assert cs.can_transition(cs.CLS_PARAM, cs.ST_EFFECTIVE, cs.ST_SUPERSEDED)
    assert not cs.can_transition(cs.CLS_PARAM, cs.ST_SUPERSEDED, cs.ST_EFFECTIVE)


@case("状态机·任务进度结算 (达标DONE / 窗口末未达标PARTIAL / 途中EXECUTING)")
def _():
    assert cs.settle_task_status(200_000, 200_000, False) == cs.ST_DONE
    assert cs.settle_task_status(200_000, 120_000, True) == cs.ST_PARTIAL
    assert cs.settle_task_status(200_000, 120_000, False) == cs.ST_EXECUTING
    assert cs.settle_task_status(0, 0, False) == cs.ST_DONE


@case("命令冲突·组合反向/同股反向/异股不冲突/重复下达")
def _():
    active = [{"command_id": "CMD_1", "cmd_type": "INCREASE_EXPOSURE"},
              {"command_id": "CMD_2", "cmd_type": "OPEN_TARGET", "ts_code": "600000.SH"}]
    c1 = cs.detect_conflicts({"cmd_type": "REDUCE_EXPOSURE"}, active)
    assert len(c1) == 1 and c1[0]["with_command_id"] == "CMD_1", c1
    c2 = cs.detect_conflicts({"cmd_type": "EXIT_STOCK", "ts_code": "600000.SH"}, active)
    assert len(c2) == 1 and c2[0]["with_cmd_type"] == "OPEN_TARGET", c2
    c3 = cs.detect_conflicts({"cmd_type": "EXIT_STOCK", "ts_code": "000001.SZ"}, active)
    assert c3 == [], c3
    c4 = cs.detect_conflicts({"cmd_type": "OPEN_TARGET", "ts_code": "600000.SH"}, active)
    assert any("重复下达" in x["reason"] for x in c4), c4
    # 冻结中的票不得建仓
    c5 = cs.detect_conflicts({"cmd_type": "OPEN_TARGET", "ts_code": "600519.SH"},
                             [{"command_id": "CMD_9", "cmd_type": "FREEZE_STOCK",
                               "ts_code": "600519.SH"}])
    assert len(c5) == 1 and "冻结" in c5[0]["reason"], c5


@case("命令目录·完整性与行业数据源未配置时置灰")
def _():
    all_cmds = cs.list_commands()
    assert len(all_cmds) >= 25, len(all_cmds)
    for c in all_cmds:
        assert c["label"] and c["cls"] in (cs.CLS_PARAM, cs.CLS_TASK), c
        spec = cs.SPECS[c["cmd_type"]]
        if spec["cls"] == cs.CLS_TASK:
            assert "planner" in spec, c["cmd_type"]
        if spec["cls"] == cs.CLS_PARAM and spec["scope"] == "global":
            assert "param_key" in spec and "value_field" in spec, c["cmd_type"]
    off = {c["cmd_type"]: c for c in cs.list_commands(sector_source_ready=False)}
    assert off["SECTOR_EXIT"]["disabled"] and off["SECTOR_CAP"]["disabled"]
    assert not off["REDUCE_EXPOSURE"]["disabled"]
    on = {c["cmd_type"]: c for c in cs.list_commands(sector_source_ready=True)}
    assert not on["SECTOR_EXIT"]["disabled"]
    assert {c["group"] for c in all_cmds} == {"A", "B", "C"}


# ================================================================ planner · 降仓
def _positions_demo():
    """设计 §3.2 场景: 规模 200 万, 组合市值 42 万。"""
    return [
        # A: 浮盈 12%, 三批齐全 (底仓 7000, 补足 3500, 加仓 3500)
        dict(ts_code="600000.SH", price=10.0, total_qty=14000, base_qty=7000,
             fill_qty=3500, add_qty=3500, dca_qty=0, t0_qty=0,
             cushion_pct=0.12, cushion_state="SOLID", neg_cushion_days=0,
             status="HOLDING", frozen_reason="NONE"),
        # B: 弱票 —— 安全垫 -9% 且连续 6 日为负
        dict(ts_code="000001.SZ", price=8.0, total_qty=10000, base_qty=10000,
             fill_qty=0, add_qty=0, dca_qty=0, t0_qty=0,
             cushion_pct=-0.09, cushion_state="NONE", neg_cushion_days=6,
             status="HOLDING", frozen_reason="NONE"),
        # C: 微盈 2%, 只有底仓 (收利润档无可卖)
        dict(ts_code="600519.SH", price=20.0, total_qty=5000, base_qty=5000,
             fill_qty=0, add_qty=0, dca_qty=0, t0_qty=0,
             cushion_pct=0.02, cushion_state="THIN", neg_cushion_days=0,
             status="HOLDING", frozen_reason="NONE"),
        # D: 浮盈 5%, 可卖 5000 股 (被命令冻结, 但冻结只禁增持不禁减持)
        dict(ts_code="300750.SZ", price=5.0, total_qty=20000, base_qty=15000,
             fill_qty=2500, add_qty=2500, dca_qty=0, t0_qty=0,
             cushion_pct=0.05, cushion_state="SOLID", neg_cushion_days=0,
             status="HOLDING", frozen_reason="COMMAND_HALT"),
    ]


@case("降仓凑额·四档全走通 (释放20万: 撤单→清弱票8万→收利润9.5万→等比微减凑齐)")
def _():
    r = pl.plan_reduce_exposure(
        release_amount=200_000, positions=_positions_demo(),
        pending_buys=[{"instruction_id": "INS_X", "ts_code": "600036.SH",
                       "qty": 1000, "amount": 10_000}])
    tiers = [i["tier"] for i in r["items"]]
    assert tiers[0] == "1_停新买", tiers
    assert r["items"][0]["action"] == pl.A_HALT
    assert r["items"][0]["cancel_instruction_id"] == "INS_X"

    weak = [i for i in r["items"] if i["tier"] == "2_清弱票"]
    assert len(weak) == 1 and weak[0]["ts_code"] == "000001.SZ", weak
    assert weak[0]["action"] == pl.A_EXIT and weak[0]["qty"] == 10000
    assert abs(weak[0]["amount"] - 80_000) < 1e-6

    harvest = [i for i in r["items"] if i["tier"] == "3_收利润"]
    # 垫子厚的先收: A(12%) 卖 7000 股 = 7万, 再 D(5%) 卖 5000 股 = 2.5万
    assert [i["ts_code"] for i in harvest] == ["600000.SH", "300750.SZ"], harvest
    assert [i["qty"] for i in harvest] == [7000, 5000], harvest
    assert all(i["action"] == pl.A_TRIM for i in harvest)

    prorata = [i for i in r["items"] if i["tier"] == "4_等比微减"]
    # 缺口 2.5 万, 按剩余市值 C(10万)/D(7.5万)/A(7万) 权重摊派 + 取整补齐
    assert {i["ts_code"] for i in prorata} == {"600519.SH", "300750.SZ", "600000.SH"}, prorata
    assert all(i["qty"] % 100 == 0 for i in prorata), prorata
    assert r["ok"] and r["gap"] == 0.0, r
    assert r["planned_amount"] >= 200_000, r
    assert r["planned_amount"] == 201_500.0, r["planned_amount"]   # 取整到手的必然轻微超额


@case("降仓凑额·不超卖 (每票累计计划量 ≤ 持仓量)")
def _():
    r = pl.plan_reduce_exposure(release_amount=350_000, positions=_positions_demo())
    hold = {p["ts_code"]: p["total_qty"] for p in _positions_demo()}
    used = {}
    for i in r["items"]:
        if i["action"] in (pl.A_EXIT, pl.A_TRIM):
            used[i["ts_code"]] = used.get(i["ts_code"], 0) + i["qty"]
    for c, q in used.items():
        assert q <= hold[c], (c, q, hold[c])


@case("降仓凑额·可减持仓不足时给缺口并标不达标")
def _():
    r = pl.plan_reduce_exposure(release_amount=1_000_000, positions=_positions_demo())
    assert not r["ok"] and r["gap"] > 0, r
    assert any("缺口" in n for n in r["notes"]), r["notes"]
    assert r["planned_amount"] <= 420_000 + 1e-6, r


@case("降仓凑额·清弱票为整票清仓, 超目标时留痕说明")
def _():
    r = pl.plan_reduce_exposure(release_amount=30_000, positions=_positions_demo())
    weak = [i for i in r["items"] if i["tier"] == "2_清弱票"]
    assert len(weak) == 1 and weak[0]["qty"] == 10000, weak      # 不为凑额拆卖弱票
    assert any("超目标" in n for n in r["notes"]), r["notes"]
    assert len([i for i in r["items"] if i["tier"] != "2_清弱票"]) == 0, r["items"]


@case("降仓凑额·跳过已有在途方案的票 + 零释放额只撤单")
def _():
    r = pl.plan_reduce_exposure(release_amount=100_000, positions=_positions_demo(),
                                exclude_codes=["000001.SZ"])
    assert all(i["ts_code"] != "000001.SZ" for i in r["items"]), r["items"]
    r2 = pl.plan_reduce_exposure(release_amount=0, positions=_positions_demo(),
                                 pending_buys=[{"instruction_id": "I1", "ts_code": "600000.SH",
                                                "qty": 100, "amount": 1000}])
    assert len(r2["items"]) == 1 and r2["items"][0]["action"] == pl.A_HALT, r2
    assert not r2["ok"]


# ================================================================ planner · 建仓/升仓/个股
def _ctx(**kw):
    d = dict(scale=2_000_000, portfolio_cap=0.60, stock_cap=0.08, max_names=15,
             portfolio_mv=800_000, names_count=5, stock_mv=0.0, is_new_name=True,
             sector=None, sector_names=0, sector_mv=0.0,
             sector_max_names=4, sector_max_ratio=0.40, cash_reserve=0.0)
    d.update(kw)
    return d


@case("建仓命令·50/25/25 分批 (12万@10元 → 6000/3000/3000, 后两批 gated 待引擎解锁)")
def _():
    r = pl.plan_open_target(ts_code="600000.SH", target_pct=0.06, price=10.0, ctx=_ctx())
    assert r["ok"], r
    assert [i["qty"] for i in r["items"]] == [6000, 3000, 3000], r["items"]
    assert [i["action"] for i in r["items"]] == [pl.A_OPEN, pl.A_FILL, pl.A_ADD]
    assert [i["gated"] for i in r["items"]] == [False, True, True]
    assert all(i["side"] == pl.SIDE_BUY for i in r["items"])
    assert abs(r["planned_amount"] - 120_000) < 1e-6


@case("建仓命令·一手合并 (12万@700元 → 合并为单批 100 股并留痕)")
def _():
    r = pl.plan_open_target(ts_code="600519.SH", target_pct=0.06, price=700.0, ctx=_ctx())
    assert r["ok"] and len(r["items"]) == 1 and r["items"][0]["qty"] == 100, r
    assert any("批次自动合并" in n for n in r["notes"]), r["notes"]


@case("建仓命令·单股上限硬拦截 + 已达目标不重复建仓")
def _():
    r = pl.plan_open_target(ts_code="600000.SH", target_pct=0.10, price=10.0, ctx=_ctx())
    assert not r["ok"] and r["items"] == [], r
    assert any(x.startswith("STOCK_CAP") for x in r["rejects"][0]["reasons"]), r["rejects"]
    r2 = pl.plan_open_target(ts_code="600000.SH", target_pct=0.06, price=10.0,
                             ctx=_ctx(stock_mv=130_000))
    assert r2["ok"] and r2["items"] == [], r2


@case("建仓命令·预留现金约束 (与总仓上限双重约束)")
def _():
    ctx = _ctx(portfolio_mv=1_750_000, portfolio_cap=0.95, cash_reserve=0.10)
    r = pl.plan_open_target(ts_code="600000.SH", target_pct=0.06, price=10.0, ctx=ctx)
    assert not r["ok"], r
    assert any(x.startswith("CASH_RESERVE") for x in r["rejects"][0]["reasons"]), r["rejects"]


@case("建仓命令·行业硬拦截 (同行业 4 只已满)")
def _():
    ctx = _ctx(sector_names_map={"半导体": 4}, sector_mv_map={"半导体": 300_000})
    r = pl.plan_increase_exposure(
        add_amount=120_000, positions=[], ctx=ctx,
        candidates=[{"ts_code": "688981.SH", "price": 20.0, "score": 0.9, "sector": "半导体"}],
        params={"stock_target_default": 0.06})
    assert r["items"] == [] and r["rejects"], r
    assert any(x.startswith("SECTOR_NAMES") for x in r["rejects"][0]["reasons"]), r["rejects"]


@case("升仓命令·既有垫厚票补到目标 + 候选池新票建仓, 累计口径校验上限")
def _():
    holdings = [dict(ts_code="600000.SH", price=10.0, total_qty=8000, base_qty=8000,
                     cushion_pct=0.08, cushion_state="SOLID", target_pct=0.06,
                     frozen_reason="NONE", sector=None)]
    cands = [{"ts_code": "688981.SH", "price": 20.0, "score": 0.9, "sector": "半导体"},
             {"ts_code": "000651.SZ", "price": 15.0, "score": 0.8, "sector": "家电"}]
    r = pl.plan_increase_exposure(add_amount=200_000, positions=holdings, candidates=cands,
                                  ctx=_ctx(), params={"stock_target_default": 0.06})
    adds = [i for i in r["items"] if i["action"] == pl.A_ADD and i["tier"] == "1_补既有"]
    assert len(adds) == 1 and adds[0]["ts_code"] == "600000.SH" and adds[0]["qty"] == 4000, adds
    new_codes = {i["ts_code"] for i in r["items"] if i["tier"].startswith("批次_")}
    assert new_codes == {"688981.SH", "000651.SZ"}, new_codes
    # 两种缺口要分开 (2026-07-31 修): 目标 20 万全分配出去了 → shortfall=0 → ok;
    # 但 000651.SZ 目标 4 万、15 元一股, 三批各自向下取整到一手只买到 37,500 —— 那 2,500
    # 是一手取整的零头, 不是"没找到票"。**planned_amount 必须是取整后的实际值**, 否则
    # 页面会说"完全满足、缺口 0"而实际少买 (07-31 实机就是这么报的)。
    assert r["ok"] and r["shortfall"] == 0, r
    assert abs(r["planned_amount"] - 197_500) < 1e-6, r["planned_amount"]
    assert abs(r["rounding_gap"] - 2_500) < 1e-6, r["rounding_gap"]
    assert abs(sum(i["amount"] for i in r["items"]) - r["planned_amount"]) < 1e-6, (
        "planned_amount 必须等于各条方案金额之和")


@case("升仓命令·冻结票与非厚垫票不补仓")
def _():
    holdings = [dict(ts_code="600000.SH", price=10.0, total_qty=8000, base_qty=8000,
                     cushion_pct=0.08, cushion_state="SOLID", target_pct=0.06,
                     frozen_reason="COMMAND_HALT", sector=None),
                dict(ts_code="000001.SZ", price=8.0, total_qty=5000, base_qty=5000,
                     cushion_pct=0.01, cushion_state="THIN", target_pct=0.06,
                     frozen_reason="NONE", sector=None)]
    r = pl.plan_increase_exposure(add_amount=100_000, positions=holdings, candidates=[],
                                  ctx=_ctx(), params={})
    assert r["items"] == [] and not r["ok"], r


@case("个股命令·清仓(含零股)/减至X%/减至0等价清仓")
def _():
    pos = dict(ts_code="600000.SH", price=10.0, total_qty=14050, base_qty=14050)
    r = pl.plan_exit_stock(ts_code="600000.SH", position=pos)
    assert r["items"][0]["qty"] == 14050, r          # 零股一并卖出, 不向下取整
    assert r["items"][0]["action"] == pl.A_EXIT

    pos2 = dict(ts_code="600000.SH", price=10.0, total_qty=20000, base_qty=10000)
    r2 = pl.plan_reduce_stock(ts_code="600000.SH", target_pct=0.05, position=pos2,
                              scale=2_000_000)
    assert r2["items"][0]["qty"] == 10000 and r2["items"][0]["action"] == pl.A_TRIM, r2

    r3 = pl.plan_reduce_stock(ts_code="600000.SH", target_pct=0.0, position=pos2,
                              scale=2_000_000)
    assert r3["items"][0]["action"] == pl.A_EXIT and r3["items"][0]["qty"] == 20000, r3

    r4 = pl.plan_reduce_stock(ts_code="600000.SH", target_pct=0.20, position=pos2,
                              scale=2_000_000)
    assert r4["items"] == [] and r4["ok"], r4        # 未超目标不减持


@case("组合命令·一键清仓(市值大的先卖)/行业清仓/行业限额/暂停买入撤单")
def _():
    ps = _positions_demo()
    for p, s in zip(ps, ["银行", "银行", "白酒", "新能源"]):
        p["sector"] = s
    r = pl.plan_liquidate_all(positions=ps, pending_buys=[
        {"instruction_id": "I1", "ts_code": "600036.SH", "qty": 100, "amount": 1000}])
    assert r["items"][0]["action"] == pl.A_HALT
    exits = [i for i in r["items"] if i["action"] == pl.A_EXIT]
    # 市值降序: A 14万 → D/C 各 10万 (同值按代码升序) → B 8万
    assert [i["ts_code"] for i in exits] == ["600000.SH", "300750.SZ", "600519.SH", "000001.SZ"]
    assert abs(r["planned_amount"] - 420_000) < 1e-6, r

    r2 = pl.plan_sector_exit(sector="银行", positions=ps)
    assert {i["ts_code"] for i in r2["items"]} == {"600000.SH", "000001.SZ"}, r2
    assert abs(r2["planned_amount"] - 220_000) < 1e-6

    # 银行占比 220/420 = 52.4% > 40%。
    # **卖出会同时缩小分子和分母**, 所以要解 (sec−x)/(port−x)=cap → x=(sec−cap·port)/(1−cap)
    # = 52,000/0.6 = 86,667, 不是 52,000 —— 按 52,000 卖完还是 51.2%, 而命令会报完成
    # (2026-07-31 修; 这条断言原来钉的就是错公式的答案)。
    r3 = pl.plan_sector_cap(sector="银行", cap=0.40, positions=ps)
    assert r3["items"] and all(i["action"] == pl.A_TRIM for i in r3["items"]), r3
    assert abs(r3["target_amount"] - 86_666.67) < 0.01, r3
    # 减后必须真的落到上限附近 (残留只该来自一手取整)
    assert 0.40 <= r3["sector_ratio_after"] <= 0.405, r3["sector_ratio_after"]
    assert any("减后仍超上限" in n for n in r3["notes"]), "残留没报出来就等于说完成了"
    r4 = pl.plan_sector_cap(sector="白酒", cap=0.40, positions=ps)
    assert r4["items"] == [] and "未超上限" in r4["notes"][0], r4

    r5 = pl.plan_halt_buy(pending_buys=[{"instruction_id": "I1", "ts_code": "600000.SH",
                                         "qty": 100, "amount": 1000}])
    assert len(r5["items"]) == 1 and r5["items"][0]["action"] == pl.A_HALT
    assert pl.plan_halt_buy(pending_buys=[])["items"] == []


# ================================================================ recon
@case("回放认领·FIFO 跨指令拆分 (一笔 4000 股成交吃两条在途指令)")
def _():
    instrs = [{"instruction_id": "INS_A", "ts_code": "600000.SH", "side": "buy", "qty": 3000,
               "exec_qty": 0, "action": "OPEN", "dispatched_at": "2026-07-27 09:35:00"},
              {"instruction_id": "INS_B", "ts_code": "600000.SH", "side": "buy", "qty": 2000,
               "exec_qty": 0, "action": "ADD", "dispatched_at": "2026-07-27 10:05:00"}]
    fills = [{"order_id": 1, "ts_code": "600000.SH", "side": "buy", "qty": 4000,
              "price": 10.0, "done_time": "2026-07-27 10:30:00"}]
    r = rc.map_fills_to_book(fills, instrs)
    assert [a["instruction_id"] for a in r["actions"]] == ["INS_A", "INS_B"], r["actions"]
    assert [a["qty"] for a in r["actions"]] == [3000, 1000], r["actions"]
    assert [a["lot_type"] for a in r["actions"]] == ["BASE", "ADD"], r["actions"]
    assert r["alerts"] == []


@case("回放认领·下发晚于成交的指令不认领 (时间守卫)")
def _():
    instrs = [{"instruction_id": "INS_LATE", "ts_code": "600000.SH", "side": "buy", "qty": 1000,
               "exec_qty": 0, "action": "OPEN", "dispatched_at": "2026-07-27 14:00:00"}]
    fills = [{"order_id": 7, "ts_code": "600000.SH", "side": "buy", "qty": 1000, "price": 10.0,
              "done_time": "2026-07-27 09:40:00"}]
    r = rc.map_fills_to_book(fills, instrs)
    assert r["actions"][0]["instruction_id"] is None, r["actions"]
    assert r["alerts"] and r["alerts"][0]["code"] == rc.ALERT_EXTERNAL


@case("回放·外部成交并入 BASE 并告警 (设计生命线条款)")
def _():
    fills = [{"order_id": 2, "ts_code": "000001.SZ", "side": "buy", "qty": 500, "price": 8.0,
              "done_time": "2026-07-27 09:40:00"}]
    r = rc.map_fills_to_book(fills, [])
    a = r["actions"][0]
    assert a["instruction_id"] is None and a["lot_type"] == "BASE" and a["qty"] == 500
    assert rc.ALERT_EXTERNAL in a["alerts"] and len(r["alerts"]) == 1


@case("回放·成交量超指令数量 → 超出部分标 OVER_FILL")
def _():
    instrs = [{"instruction_id": "INS_A", "ts_code": "600000.SH", "side": "buy", "qty": 1000,
               "exec_qty": 0, "action": "OPEN", "dispatched_at": "2026-07-27 09:30:00"}]
    fills = [{"order_id": 3, "ts_code": "600000.SH", "side": "buy", "qty": 1500, "price": 10.0,
              "done_time": "2026-07-27 09:50:00"}]
    r = rc.map_fills_to_book(fills, instrs)
    assert len(r["actions"]) == 2 and r["actions"][1]["qty"] == 500
    assert rc.ALERT_OVERFILL in r["actions"][1]["alerts"], r["actions"]


@case("回放·幂等 (已入账 order_id 跳过) 与游标推进")
def _():
    fills = [{"order_id": 10, "ts_code": "600000.SH", "side": "buy", "qty": 100, "price": 10.0},
             {"order_id": 11, "ts_code": "600000.SH", "side": "buy", "qty": 100, "price": 10.0}]
    r = rc.map_fills_to_book(fills, [], known_order_ids={10})
    assert r["skipped"] == 1 and len(r["actions"]) == 1, r
    assert rc.next_cursor(fills, cursor=5) == 11
    assert rc.next_cursor([], cursor=5) == 5
    assert rc.next_cursor(fills, cursor=99) == 99


@case("回放·卖出成交按核销次序分配, 批次不足时告警留差额")
def _():
    lots = [{"lot_id": "L1", "lot_type": "BASE", "qty": 1000, "open_date": 20260701},
            {"lot_id": "L2", "lot_type": "ADD", "qty": 500, "open_date": 20260710}]
    ok = rc.apply_sell_to_lots(lots, 1200)
    assert [x["lot_id"] for x in ok["alloc"]] == ["L2", "L1"], ok
    assert [x["qty"] for x in ok["alloc"]] == [500, 700] and ok["short"] == 0
    short = rc.apply_sell_to_lots(lots, 2000)
    assert short["short"] == 500 and short["alerts"][0]["code"] == rc.ALERT_SELL_NO_LOT


@case("回放·成交数量与价格口径回退 (filled_* 缺失时用 order_*)")
def _():
    assert rc.fill_qty_price({"order_quantity": 700, "order_price": 9.87}) == (700, 9.87)
    assert rc.fill_qty_price({"filled_qty": 300, "order_quantity": 700,
                              "filled_price": 9.9, "order_price": 9.87}) == (300, 9.9)
    assert rc.fill_qty_price({}) == (0, 0.0)


@case("对账·三类差异识别 (账本缺/账本多/数量不符)")
def _():
    book = [{"ts_code": "600000.SH", "total_qty": 6000},
            {"ts_code": "000001.SZ", "total_qty": 1000},
            {"ts_code": "600519.SH", "total_qty": 300}]
    ds = [{"ts_code": "600000.SH", "qty": 6000},
          {"ts_code": "000001.SZ", "qty": 0},
          {"ts_code": "300750.SZ", "qty": 800},
          {"ts_code": "600519.SH", "qty": 500}]
    d = {x["ts_code"]: x for x in rc.diff_positions(book, ds)}
    assert "600000.SH" not in d
    assert d["000001.SZ"]["kind"] == "EXTRA_IN_BOOK" and d["000001.SZ"]["delta"] == -1000
    assert d["300750.SZ"]["kind"] == "MISSING_IN_BOOK" and d["300750.SZ"]["delta"] == 800
    assert d["600519.SH"]["kind"] == "QTY_MISMATCH" and d["600519.SH"]["delta"] == 200


@case("对账·以下游为准生成修正动作 (补 RECON 批 / 冲销批次)")
def _():
    diffs = rc.diff_positions([{"ts_code": "600000.SH", "total_qty": 1000}],
                              [{"ts_code": "600000.SH", "qty": 1500}])
    fx = rc.build_recon_fixes(diffs, price_map={"600000.SH": 10.0})
    assert fx[0]["op"] == "ADD_RECON_LOT" and fx[0]["qty"] == 500 and not fx[0]["need_price"]
    diffs2 = rc.diff_positions([{"ts_code": "600000.SH", "total_qty": 1000}],
                               [{"ts_code": "600000.SH", "qty": 600}])
    lots = {"600000.SH": [{"lot_id": "L1", "lot_type": "BASE", "qty": 1000,
                           "open_date": 20260701}]}
    fx2 = rc.build_recon_fixes(diffs2, price_map={}, lots_map=lots)
    assert fx2[0]["op"] == "REDUCE_LOTS" and fx2[0]["qty"] == 400
    assert fx2[0]["alloc"] == [{"lot_id": "L1", "qty": 400}], fx2


@case("对账·连续不一致升级 (1-2日 WARN, 满 3 日 ERROR)")
def _():
    assert rc.recon_severity(0) == rc.SEV_OK
    assert rc.recon_severity(1) == rc.SEV_WARN
    assert rc.recon_severity(3) == rc.SEV_ERROR
    s = rc.summarize_recon([{"kind": "QTY_MISMATCH"}, {"kind": "QTY_MISMATCH"}], 3)
    assert s["diff_count"] == 2 and s["by_kind"]["QTY_MISMATCH"] == 2
    assert s["severity"] == rc.SEV_ERROR
    assert rc.summarize_recon([], 3)["severity"] == rc.SEV_OK


@case("除权检测·10送10 判为除权; 比例不吻合判 MISMATCH 待人工")
def _():
    r = rc.detect_ex_right(1000, 2000, 20.0, 10.0)
    assert r["kind"] == "EX_RIGHT" and abs(r["ratio"] - 2.0) < 1e-6, r
    assert rc.detect_ex_right(1000, 1000, 20.0, 20.0) is None
    bad = rc.detect_ex_right(1000, 2000, 20.0, 18.0)
    assert bad["kind"] == "MISMATCH" and "待人工" in bad["reason"], bad
    nopx = rc.detect_ex_right(1000, 2000, 0, 10.0)
    assert nopx["kind"] == "MISMATCH", nopx
    # 10 送 3 (容差内)
    r2 = rc.detect_ex_right(1000, 1300, 13.0, 10.0)
    assert r2["kind"] == "EX_RIGHT" and abs(r2["ratio"] - 1.3) < 1e-6, r2


@case("除权调整·批次数量与成本按比例调整, 总成本不变")
def _():
    lots = [{"lot_id": "L1", "lot_type": "BASE", "qty": 1000, "open_price": 20.0},
            {"lot_id": "L2", "lot_type": "ADD", "qty": 500, "open_price": 22.0}]
    out = rc.apply_ex_right(lots, 2.0)
    assert [l["qty"] for l in out] == [2000, 1000]
    assert [l["open_price"] for l in out] == [10.0, 11.0]
    before = sum(l["qty"] * l["open_price"] for l in lots)
    after = sum(l["qty"] * l["open_price"] for l in out)
    assert abs(before - after) < 1e-6, (before, after)


@case("T+1 可用量·日初重置 / 当日买入不增可卖 / 卖出扣减")
def _():
    assert rc.daily_avail_reset(6000) == 6000
    assert rc.avail_after_fill(6000, "buy", 2000) == 6000      # 当日买入 T+1 才可卖
    assert rc.avail_after_fill(6000, "sell", 2000) == 4000
    assert rc.avail_after_fill(1000, "sell", 5000) == 0
    assert rc.sellable_today(4000, 10000) == 4000
    assert rc.sellable_today(0, 100) == 0


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


if __name__ == "__main__":
    main()
