552 lines
26 KiB
Python
552 lines
26 KiB
Python
# -*- 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()
|