# -*- coding: utf-8 -*- """ 第十五批模块单测 (紧急清仓两处修复, 零外部依赖) ================================================ 运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch15_units.py 背景 (2026-08-19 实机): 盘中下一键清仓, 部分票卖完、剩三支拖到次日早盘才清。排查结论两条: 甲, 当日配额把"今日已成交量"重复扣了一次 —— 配额是拿"剩余未成交量"算的(已扣过成交), 又从"今日已投放量"里再扣一遍今日成交, 同一笔成交扣两次, 当天成交越多、可再投放越快 变负, 于是紧急清仓发一两笔就误判"当日配额已出完", 连 14:45 兜底都被挡, 要等次日。 乙, 清仓命令没有立刻停掉相关票的买入侧 —— 挂网格的票(002128)清场撤策略要等持仓归零才触发, 于是网格整天在买、清仓在卖, 买进来的又是 T+1 当天卖不掉, 清仓在追一个自己还在被买的持仓。 本批覆盖: * _inflight_today 只算在途、已终态一律不算 (甲的口径修正); * 同一批 children 下, _consumed_today 与 _inflight_today 的差, 以及内置择时在两种口径下 FIRE / 配额已出完 的分野 (直接钉死根因, 用真函数不打桩); * _stop_buyside_for_exit 撤策略 + 驳回买入提议, 不动卖出提议与无关票 (乙); * plan_command 下 LIQUIDATE_ALL 时确实撤掉了持仓票的活跃策略 (乙的接线, 真实走一遍). """ import os import sys import traceback from datetime import datetime sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.core import exec_timing as et # noqa: E402 from app.services import executor # noqa: E402 from app.services import command_service as csvc # noqa: E402 from app.repo import qmt_repo, pms_repo # noqa: E402 RESULTS = [] def case(name): def deco(fn): RESULTS.append((name, fn)) return fn return deco YMD = 20260820 # 择时内置B 的参数快照 (卖出用得到的几项) EXEC_PRM = {"sell_avoid_open_min": 30, "eod_force_time": "14:45", "eod_force_discount": 0.998, "urgent_sell_discount": 0.995, "sell_bucket_times": ""} DAY = {"price": 27.6, "vwap": 27.5, "high": 27.8, "low": 27.3, "day_chg_from_open": 0.01, "bars": 30} NOW = datetime(2026, 8, 20, 13, 10) # 下午盘中, 未到 14:45 # ---------------------------------------------------------------- 出口行打桩 class _FakeQmt: """按 ref 给出订单终态/在途, 供 _consumed_today / _inflight_today 反查。""" def __init__(self, orders): self.orders = orders # ref -> {"status","cum_qty"} def get_order(self, ref): return self.orders.get(ref) def _patch_qmt(orders): fake = _FakeQmt(orders) executor.qmt_repo.get_order = fake.get_order def _unpatch_qmt(): executor.qmt_repo.get_order = qmt_repo.get_order # ================================================================ 甲: 配额口径 @case("配额口径·_inflight_today 只算在途, 已成交/被拒/过期一律不算") def _(): children = [ {"ymd": YMD, "qty": 900, "ref": "r_filled"}, # 已全成 {"ymd": YMD, "qty": 500, "ref": "r_rej"}, # 被拒 {"ymd": YMD, "qty": 400, "ref": "r_live"}, # 仍在途 {"ymd": YMD, "qty": 100, "ref": "manual:x"}, # 影子/手动: 保守当在途 {"ymd": 20260819, "qty": 999, "ref": "r_old"}, # 不是今天, 忽略 ] _patch_qmt({"r_filled": {"status": qmt_repo.OS_FILLED, "cum_qty": 900}, "r_rej": {"status": qmt_repo.OS_REJECTED, "cum_qty": 0}, "r_live": {"status": qmt_repo.OS_ACCEPTED, "cum_qty": 0}}) try: # 在途口径: 只有 r_live(400) + manual(100) = 500 assert executor._inflight_today(children, YMD) == 500, \ executor._inflight_today(children, YMD) # 老口径 _consumed_today: 成交 900 + 在途 400 + manual 100 = 1400 (被拒 0) assert executor._consumed_today(children, YMD) == 1400, \ executor._consumed_today(children, YMD) finally: _unpatch_qmt() @case("配额口径·同一批成交下, 老口径把紧急清仓benched, 新口径继续FIRE (根因钉死)") def _(): # 场景复刻 002128: 委托 2300, 今日已发两笔 900+900, 第一笔全成、第二笔在途。 # 剩余 = 2300 - 900(已成) = 1400。窗口末日, 配额 = 全部剩余 = 1400。 children = [{"ymd": YMD, "qty": 900, "ref": "d1"}, {"ymd": YMD, "qty": 900, "ref": "d2"}] _patch_qmt({"d1": {"status": qmt_repo.OS_FILLED, "cum_qty": 900}, "d2": {"status": qmt_repo.OS_ACCEPTED, "cum_qty": 0}}) try: remaining = 1400 quota = et.daily_quota(remaining, 1, allow_odd_tail=True) # 末日 → 1400 assert quota == 1400, quota old = executor._consumed_today(children, YMD) # 900 + 900 = 1800 new = executor._inflight_today(children, YMD) # 0 + 900 = 900 assert old == 1800 and new == 900, (old, new) # 老口径: left = 1400 - 1800 < 0 → "当日配额已出完", 紧急也被挡 (bug) d_old = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM, is_last_day=True, is_command=True, urgent=True, fired_today=old, quota=quota) assert d_old["action"] == et.ACT_WAIT and "配额已出完" in d_old["reason"], d_old # 新口径: left = 1400 - 900 = 500 > 0 → 紧急直通 FIRE, 还能再投 500 d_new = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM, is_last_day=True, is_command=True, urgent=True, fired_today=new, quota=quota) assert d_new["action"] == et.ACT_FIRE and d_new["qty_hint"] == 500, d_new finally: _unpatch_qmt() @case("配额口径·全部在途(没有可再投放)时正确等待, 不重复下单") def _(): children = [{"ymd": YMD, "qty": 1400, "ref": "d1"}] _patch_qmt({"d1": {"status": qmt_repo.OS_ACCEPTED, "cum_qty": 0}}) try: remaining = 1400 quota = et.daily_quota(remaining, 1, allow_odd_tail=True) new = executor._inflight_today(children, YMD) # 1400 在途 assert new == 1400 d = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM, is_last_day=True, is_command=True, urgent=True, fired_today=new, quota=quota) # left = 0 → 等在途成交, 不再投 (避免超卖/重复下单) assert d["action"] == et.ACT_WAIT, d finally: _unpatch_qmt() @case("配额口径·run_tick 选口径: 紧急或末日走在途口径, 普通多日走老口径") def _(): # 用源码级断言确认 run_tick 里的分流条件没被改回去 (轻量, 防回归) import inspect src = inspect.getsource(executor.run_tick) assert "_inflight_today" in src and "is_urgent or is_last" in src, "run_tick 分流丢了" assert "_consumed_today" in src, "多日节流口径丢了" # ================================================================ 乙: 命令覆盖冲突 class _Rec: def __init__(self): self.strategies = [] self.proposals = [] self.cancelled = [] self.declined = [] self.ledger = [] def _patch_stop(rec, strategies, proposals): from app.services import strategy_service rec._orig = (pms_repo.list_strategies, pms_repo.list_proposals, pms_repo.decide_proposal, pms_repo.insert_ledger, strategy_service.set_status) pms_repo.list_strategies = lambda **kw: list(strategies) pms_repo.list_proposals = lambda **kw: list(proposals) def _decide(pid, decision): rec.declined.append((pid, decision)) return True pms_repo.decide_proposal = _decide pms_repo.insert_ledger = lambda **kw: rec.ledger.append(kw) or 1 def _setst(sid, status, by="user"): rec.cancelled.append((sid, status, by)) return {"ok": True, "status": status} strategy_service.set_status = _setst def _unpatch_stop(rec): from app.services import strategy_service (pms_repo.list_strategies, pms_repo.list_proposals, pms_repo.decide_proposal, pms_repo.insert_ledger, strategy_service.set_status) = rec._orig @case("命令覆盖冲突·撤活跃策略 + 驳回买入提议, 不动卖出提议与无关票") def _(): rec = _Rec() strategies = [{"strategy_id": "STR_A", "ts_code": "002128.SZ", "type": "GRID"}, {"strategy_id": "STR_B", "ts_code": "600000.SH", "type": "T0"}] # 无关票 proposals = [{"proposal_id": "P_buy", "ts_code": "002128.SZ", "action": "DCA"}, # 买, 驳 {"proposal_id": "P_sell", "ts_code": "002128.SZ", "action": "TRIM"}, # 卖, 留 {"proposal_id": "P_other", "ts_code": "600000.SH", "action": "OPEN"}] # 无关票 _patch_stop(rec, strategies, proposals) try: out = csvc._stop_buyside_for_exit(["002128.SZ"], "CMD_T_0001") assert out["strategies"] == ["STR_A"], out # 只撤本票策略 assert out["proposals"] == ["P_buy"], out # 只驳本票买入提议 assert rec.cancelled == [("STR_A", "CANCELLED", "command")], rec.cancelled assert rec.declined == [("P_buy", "DECLINED")], rec.declined assert any(x.get("action") == "CLEANUP" for x in rec.ledger) # 留痕 assert not out["errors"], out finally: _unpatch_stop(rec) @case("命令覆盖冲突·空票集不动任何东西") def _(): rec = _Rec() _patch_stop(rec, [{"strategy_id": "STR_A", "ts_code": "002128.SZ"}], []) try: out = csvc._stop_buyside_for_exit([], "CMD_T_0002") assert out == {"strategies": [], "proposals": [], "errors": []}, out assert not rec.cancelled finally: _unpatch_stop(rec) @case("命令覆盖冲突·plan_command 下 LIQUIDATE_ALL 真的撤掉持仓票策略 (接线)") def _(): from app.core import command_spec as cs from app.services import portfolio, strategy_service orig = (pms_repo.update_command, pms_repo.insert_plans, pms_repo.list_instructions, pms_repo.list_strategies, pms_repo.list_proposals, pms_repo.insert_ledger, pms_repo.list_plans, portfolio.positions_view, strategy_service.set_status, csvc.param_store.get_int) rec = _Rec() held = [{"ts_code": "002128.SZ", "total_qty": 300, "avail_qty": 300, "price": 27.6, "price_ok": True, "base_qty": 0}] try: pms_repo.update_command = lambda *a, **k: 1 pms_repo.insert_plans = lambda rows: len(rows) pms_repo.list_plans = lambda **kw: [] # 没有在途方案 pms_repo.list_instructions = lambda **kw: [] # 没有在途买单 pms_repo.list_strategies = lambda **kw: [ {"strategy_id": "STR_A", "ts_code": "002128.SZ", "type": "GRID"}] pms_repo.list_proposals = lambda **kw: [] pms_repo.insert_ledger = lambda **kw: rec.ledger.append(kw) or 1 portfolio.positions_view = lambda **kw: { "held": held, "positions": held, "params": {"scale": 2_000_000, "weak_neg_days": 5}, "totals": {"scale": 2_000_000}, "sector_ready": True} strategy_service.set_status = lambda sid, status, by="user": ( rec.cancelled.append((sid, status, by)) or {"ok": True, "status": status}) csvc.param_store.get_int = lambda k, d=0: d cmd = {"cmd_type": "LIQUIDATE_ALL", "command_id": "CMD_20260820_0009", "params": {"window_tdays": 1, "confirm": "YES"}} r = csvc.plan_command(cmd) assert r["status"] == cs.ST_EXECUTING, r assert ("STR_A", "CANCELLED", "command") in rec.cancelled, rec.cancelled # 命令进度里带停买入侧的回执 assert r["plan"]["stopped_buyside"]["strategies"] == ["STR_A"], r["plan"] finally: (pms_repo.update_command, pms_repo.insert_plans, pms_repo.list_instructions, pms_repo.list_strategies, pms_repo.list_proposals, pms_repo.insert_ledger, pms_repo.list_plans, portfolio.positions_view, strategy_service.set_status, csvc.param_store.get_int) = orig # ================================================================ runner def main(): passed = failed = 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()