tradingSystem/scripts/test_batch15_units.py

365 lines
18 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_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 unit_test_guard # noqa: F401,单独运行也禁止联网
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()