# -*- 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 assert r["ok"] and abs(r["planned_amount"] - 200_000) < 1e-6, r @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% → 需减 52,000 元 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"] - 52_000) < 1e-6, r3 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()