286 lines
13 KiB
Python
286 lines
13 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 / 配额已出完 的分野 (直接钉死根因, 用真函数不打桩);
|
|
* _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()
|