# -*- 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 / WAIT 的分野 (直接钉死根因, 用真函数不打桩); * _stop_buyside_for_exit 撤策略 + 驳回买入提议 + 撤在途买单, 不动卖出提议与无关票 (乙); * plan_command 下 LIQUIDATE_ALL 时确实撤掉了持仓票的活跃策略 (乙的接线, 真实走一遍); * 丙(文案拆分): 紧急/末日单 left<=0 报"已全部在途, 等成交回来", 普通多日单才报"当日配额已出完", hard_gate 与策略即时两条路各测一遍; * 丁(减仓看情况): _pending_buys(exclude_strategy=True) 剔掉策略腿 —— 减仓不掐网格/做T, 清仓与全局暂停买入仍全撤. """ 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 → 直接 WAIT, 紧急清仓被挡在这道判断前 (bug)。 # (文案这批已拆分: 紧急/末日单 left<=0 报"已全部在途", 普通多日单才报"当日配额已出完"; # 这里 urgent=True 走前者。要钉的是 action=WAIT —— 老口径把紧急清仓 benched 了。) 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.cancelled_ins = [] # 被撤的在途买入指令 id self.ledger = [] def _patch_stop(rec, strategies, proposals, instructions=()): from app.services import strategy_service, executor as _ex rec._orig = (pms_repo.list_strategies, pms_repo.list_proposals, pms_repo.decide_proposal, pms_repo.insert_ledger, pms_repo.list_instructions, strategy_service.set_status, _ex.cancel_instruction) pms_repo.list_strategies = lambda **kw: list(strategies) pms_repo.list_proposals = lambda **kw: list(proposals) pms_repo.list_instructions = lambda **kw: list(instructions) 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 _cancel_ins(iid, reason="页面人工撤销"): rec.cancelled_ins.append(iid) return {"ok": True} _ex.cancel_instruction = _cancel_ins def _unpatch_stop(rec): from app.services import strategy_service, executor as _ex (pms_repo.list_strategies, pms_repo.list_proposals, pms_repo.decide_proposal, pms_repo.insert_ledger, pms_repo.list_instructions, strategy_service.set_status, _ex.cancel_instruction) = 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"}] # 无关票 # 在途买单: 本票一张 (要撤) + 无关票一张 (要留) instructions = [{"instruction_id": "INS_buy", "ts_code": "002128.SZ", "side": "buy"}, {"instruction_id": "INS_other", "ts_code": "600000.SH", "side": "buy"}] _patch_stop(rec, strategies, proposals, instructions=instructions) 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 out["instructions"] == ["INS_buy"], out # 只撤本票在途买单 assert rec.cancelled == [("STR_A", "CANCELLED", "command")], rec.cancelled assert rec.declined == [("P_buy", "DECLINED")], rec.declined assert rec.cancelled_ins == ["INS_buy"], rec.cancelled_ins # 无关票在途买单不动 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"}], [], instructions=[{"instruction_id": "INS_x", "ts_code": "002128.SZ", "side": "buy"}]) try: out = csvc._stop_buyside_for_exit([], "CMD_T_0002") assert out == {"strategies": [], "proposals": [], "instructions": [], "errors": []}, out assert not rec.cancelled and not rec.cancelled_ins 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 # ================================================================ 丙: 文案拆分 (在途 / 配额已出完) @case("文案拆分·quota_wait_reason: 紧急或末日报'已全部在途', 普通多日报'当日配额已出完'") def _(): assert et.quota_wait_reason(urgent=True, is_last_day=False) == "已全部在途, 等成交回来" assert et.quota_wait_reason(urgent=False, is_last_day=True) == "已全部在途, 等成交回来" assert et.quota_wait_reason(urgent=False, is_last_day=False) == "当日配额已出完" @case("文案拆分·普通多日单 left<=0 仍报'当日配额已出完' (行为不变)") def _(): d = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM, is_last_day=False, is_command=True, urgent=False, fired_today=1000, quota=1000) # 今日已投放=配额 → left=0 assert d["action"] == et.ACT_WAIT and d["reason"] == "当日配额已出完", d @case("文案拆分·紧急/末日单 left<=0 改报'已全部在途, 等成交回来'") def _(): d = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM, is_last_day=True, is_command=True, urgent=True, fired_today=1000, quota=1000) # 该出的全挂在券商, left=0 assert d["action"] == et.ACT_WAIT and d["reason"] == "已全部在途, 等成交回来", d @case("文案拆分·策略即时(网格)同样分口径: 末日报在途, 非末日报配额到顶") def _(): from app.services import exec_advisor # 网格是 window=1 末日单, left<=0 = 该出的档都挂着等成交 d1 = exec_advisor._strategy_immediate(side="buy", now=NOW, day=DAY, quota=100, fired_today=100, is_last_day=True) assert d1["action"] == et.ACT_WAIT and d1["reason"] == "已全部在途, 等成交回来", d1 # 退一步防串味: 若某笔不是末日单, 该报配额到顶 d2 = exec_advisor._strategy_immediate(side="buy", now=NOW, day=DAY, quota=100, fired_today=100, is_last_day=False, urgent=False) assert d2["reason"] == "当日配额已出完", d2 # ================================================================ 丁: 减仓不撤策略腿在途买单 @case("减仓口径·_pending_buys(exclude_strategy=True) 剔策略腿, 默认(清仓/暂停买入)全带") def _(): orig = pms_repo.list_instructions rows = [{"instruction_id": "I_auto", "ts_code": "600000.SH", "qty": 100, "limit_price": 10.0, "origin_type": "plan"}, {"instruction_id": "I_prop", "ts_code": "600000.SH", "qty": 200, "limit_price": 10.0, "origin_type": "proposal"}, {"instruction_id": "I_grid", "ts_code": "002128.SZ", "qty": 300, "limit_price": 27.6, "origin_type": "strategy"}] try: pms_repo.list_instructions = lambda **kw: list(rows) keep_all = {b["instruction_id"] for b in csvc._pending_buys()} no_strat = {b["instruction_id"] for b in csvc._pending_buys(exclude_strategy=True)} assert keep_all == {"I_auto", "I_prop", "I_grid"}, keep_all # 清仓/全局暂停买入: 全撤 assert no_strat == {"I_auto", "I_prop"}, no_strat # 减仓: 网格/做T 腿留着继续跑 finally: pms_repo.list_instructions = 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()