tradingSystem/scripts/test_batch15_units.py

364 lines
18 KiB
Python

# -*- 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()