tradingSystem/scripts/test_batch2_units.py

566 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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%。
# **卖出会同时缩小分子和分母**, 所以要解 (secx)/(portx)=cap → x=(seccap·port)/(1cap)
# = 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()