tradingSystem/scripts/test_batch15_units.py

286 lines
13 KiB
Python
Raw Normal View History

2026-08-20 10:23:33 +08:00
# -*- 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()