2026-07-28 09:10:07 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
|
|
|
|
|
第三批模块单测 (实机运行, 零外部依赖)
|
|
|
|
|
|
======================================
|
|
|
|
|
|
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch3_units.py
|
|
|
|
|
|
覆盖: exec_timing 分日配额/分笔/买卖出手判定/兜底/顺延/窗口收口;
|
|
|
|
|
|
rule_gate 减持放行口径、增持全约束、命令与自主的刹车差别。
|
|
|
|
|
|
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
|
|
|
|
|
|
"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import traceback
|
|
|
|
|
|
|
|
|
|
|
|
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.core import rule_gate as rg # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
RESULTS = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def case(name):
|
|
|
|
|
|
def deco(fn):
|
|
|
|
|
|
RESULTS.append((name, fn))
|
|
|
|
|
|
return fn
|
|
|
|
|
|
return deco
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PRM = {"sell_avoid_open_min": 30, "buy_halt_dayup": 0.05,
|
|
|
|
|
|
"eod_force_time": "14:45", "eod_force_discount": 0.998}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def day(**kw):
|
|
|
|
|
|
d = {"price": 10.0, "vwap": 10.0, "high": 10.5, "low": 9.5, "open": 10.0,
|
|
|
|
|
|
"day_chg_from_open": 0.0, "bars": 60}
|
|
|
|
|
|
d.update(kw)
|
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ exec_timing
|
|
|
|
|
|
@case("分日配额·整除/上取整到一手/最后一日全出/零股尾巴并入")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
assert et.daily_quota(6000, 3) == 2000
|
|
|
|
|
|
assert et.daily_quota(5000, 3) == 1700 # 1666.7 → 上取整到一手
|
|
|
|
|
|
assert et.daily_quota(1000, 1) == 1000 # 最后一日全出
|
|
|
|
|
|
assert et.daily_quota(150, 3) == 150 # 尾巴不足一手 → 一次出完
|
|
|
|
|
|
assert et.daily_quota(0, 3) == 0
|
|
|
|
|
|
assert et.daily_quota(100, 5) == 100
|
|
|
|
|
|
# 整票清仓允许零股
|
|
|
|
|
|
assert et.daily_quota(14050, 3, allow_odd_tail=True) == 4700
|
|
|
|
|
|
assert et.daily_quota(14050, 1, allow_odd_tail=True) == 14050
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("分笔·配额切成 N 笔, 余数并入最后一笔")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
assert et.slice_qty(3000, 3) == [1000, 1000, 1000]
|
|
|
|
|
|
assert et.slice_qty(1000, 3) == [300, 300, 400]
|
|
|
|
|
|
assert et.slice_qty(100, 3) == [100]
|
|
|
|
|
|
assert et.slice_qty(0, 3) == []
|
|
|
|
|
|
assert et.slice_qty(2500, 1) == [2500]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("卖出择时·避开开盘30分钟 → 站上均价才出手 → 限价打折")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
r = et.decide(side="sell", now="09:45", day=day(), params=PRM, is_last_day=False, quota=2000)
|
|
|
|
|
|
assert r["action"] == et.ACT_WAIT and "避开开盘" in r["reason"], r
|
|
|
|
|
|
r = et.decide(side="sell", now="10:05", day=day(price=10.2, vwap=10.0), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=2000)
|
|
|
|
|
|
assert r["action"] == et.ACT_FIRE and r["limit_price"] == 10.18, r # 10.2×0.998
|
|
|
|
|
|
r = et.decide(side="sell", now="10:05", day=day(price=9.8, vwap=10.0), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=2000)
|
|
|
|
|
|
assert r["action"] == et.ACT_WAIT and "等更好的价" in r["reason"], r
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("卖出择时·14:45 兜底强制出手 (不管均价)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
r = et.decide(side="sell", now="14:45", day=day(price=9.5, vwap=10.0), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=2000)
|
|
|
|
|
|
assert r["action"] == et.ACT_FIRE and r["forced"] is True, r
|
|
|
|
|
|
assert r["limit_price"] == 9.48, r # 9.5×0.998
|
|
|
|
|
|
assert et.decide(side="sell", now="14:44", day=day(price=9.5, vwap=10.0), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=2000)["action"] == et.ACT_WAIT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("买入择时·跌破均价才买 / 回踩带买 / 不追高停手")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
r = et.decide(side="buy", now="10:05", day=day(price=9.8, vwap=10.0), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=3000)
|
|
|
|
|
|
assert r["action"] == et.ACT_FIRE and r["limit_price"] == 9.82, r # 9.8×1.002
|
|
|
|
|
|
r = et.decide(side="buy", now="10:05", day=day(price=10.3, vwap=10.0), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=3000)
|
|
|
|
|
|
assert r["action"] == et.ACT_WAIT and "等回调" in r["reason"], r
|
|
|
|
|
|
r = et.decide(side="buy", now="10:05", day=day(price=10.3, vwap=10.0, support=10.2),
|
|
|
|
|
|
params=PRM, is_last_day=False, quota=3000)
|
|
|
|
|
|
assert r["action"] == et.ACT_FIRE and "回踩带" in r["reason"], r
|
|
|
|
|
|
r = et.decide(side="buy", now="10:05", day=day(price=11.0, vwap=10.0,
|
|
|
|
|
|
day_chg_from_open=0.06),
|
|
|
|
|
|
params=PRM, is_last_day=False, quota=3000)
|
|
|
|
|
|
assert r["action"] == et.ACT_STOP and "不追高" in r["reason"], r
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("买入择时·窗口末日 14:45 强制完成, 非末日则顺延")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
r = et.decide(side="buy", now="14:50", day=day(price=10.5, vwap=10.0), params=PRM,
|
|
|
|
|
|
is_last_day=True, quota=3000)
|
|
|
|
|
|
assert r["action"] == et.ACT_FIRE and r["forced"] and r["limit_price"] == 10.52, r
|
|
|
|
|
|
r = et.decide(side="buy", now="14:50", day=day(price=10.5, vwap=10.0), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=3000)
|
|
|
|
|
|
assert r["action"] == et.ACT_WAIT and "顺延次日" in r["reason"], r
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("择时·停牌/一字板/非交易时段/配额出完 一律不动")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
assert et.decide(side="sell", now="10:05", day=day(price=0), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=100)["action"] == et.ACT_SKIP
|
|
|
|
|
|
assert et.decide(side="sell", now="10:05", day=day(halted=True), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=100)["action"] == et.ACT_SKIP
|
|
|
|
|
|
assert et.decide(side="buy", now="10:05", day=day(limit_up=True), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=100)["action"] == et.ACT_SKIP
|
|
|
|
|
|
r = et.decide(side="sell", now="10:05", day=day(limit_down=True), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=100)
|
|
|
|
|
|
assert r["action"] == et.ACT_SKIP and "顺延" in r["reason"], r
|
|
|
|
|
|
assert et.decide(side="sell", now="12:00", day=day(), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=100)["action"] == et.ACT_WAIT
|
|
|
|
|
|
r = et.decide(side="sell", now="10:05", day=day(price=10.2), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=1000, fired_today=1000)
|
|
|
|
|
|
assert r["action"] == et.ACT_WAIT and "配额已出完" in r["reason"], r
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("择时·时点解析与交易时段判定")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
assert et.hm_to_min("14:45") == 885 and et.hm_to_min((9, 30)) == 570
|
|
|
|
|
|
assert et.in_session(570) and et.in_session(690) and et.in_session(780)
|
|
|
|
|
|
assert not et.in_session(569) and not et.in_session(700) and not et.in_session(901)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("窗口收口·命令类置部分完成, 自主类作废, 未到期继续")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
assert et.window_verdict(remaining_qty=0, tdays_left=0, is_command=True)["verdict"] == "DONE"
|
|
|
|
|
|
assert et.window_verdict(remaining_qty=500, tdays_left=2,
|
|
|
|
|
|
is_command=True)["verdict"] == "RUNNING"
|
|
|
|
|
|
v = et.window_verdict(remaining_qty=500, tdays_left=0, is_command=True)
|
|
|
|
|
|
assert v["verdict"] == "PARTIAL" and "告警" in v["note"], v
|
|
|
|
|
|
assert et.window_verdict(remaining_qty=500, tdays_left=0,
|
|
|
|
|
|
is_command=False)["verdict"] == "EXPIRED"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 15:09:37 +08:00
|
|
|
|
@case("窗口收口·自主卖出不作废 (2026-08-17: 宁可买不上, 不能卖不掉)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
# 信号清仓/保垫减仓窗口耗尽 → 保持在途按末日节奏继续, 不作废
|
|
|
|
|
|
v = et.window_verdict(remaining_qty=500, tdays_left=0, is_command=False, side="sell")
|
|
|
|
|
|
assert v["verdict"] == "PARTIAL" and "不作废" in v["note"], v
|
|
|
|
|
|
# 自主买入照旧作废; 命令类两个方向都置部分完成 (口径不变)
|
|
|
|
|
|
assert et.window_verdict(remaining_qty=500, tdays_left=0,
|
|
|
|
|
|
is_command=False, side="buy")["verdict"] == "EXPIRED"
|
|
|
|
|
|
assert et.window_verdict(remaining_qty=500, tdays_left=0,
|
|
|
|
|
|
is_command=True, side="sell")["verdict"] == "PARTIAL"
|
|
|
|
|
|
# side 不传 = 旧口径 (兼容既有调用)
|
|
|
|
|
|
assert et.window_verdict(remaining_qty=500, tdays_left=0,
|
|
|
|
|
|
is_command=False)["verdict"] == "EXPIRED"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("紧急卖出直通·不避开盘不等均价, 限价更激进, 兜底时点后转 forced")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
prm = {**PRM, "urgent_sell_discount": 0.995}
|
|
|
|
|
|
# 09:35 开盘半小时内 + 现价低于均价 —— 普通卖出会 WAIT, 紧急直接 FIRE
|
|
|
|
|
|
r = et.decide(side="sell", now="09:35", day=day(price=9.8, vwap=10.0), params=prm,
|
|
|
|
|
|
is_last_day=False, quota=2000, urgent=True)
|
|
|
|
|
|
assert r["action"] == et.ACT_FIRE and r["forced"] is False, r
|
|
|
|
|
|
assert r["limit_price"] == 9.75, r # 9.8×0.995=9.751 → round 9.75
|
|
|
|
|
|
assert "紧急" in r["reason"], r
|
|
|
|
|
|
# 14:50 (兜底时点后) 紧急单转 forced —— 挂到收盘不被 TTL 撤回
|
|
|
|
|
|
r = et.decide(side="sell", now="14:50", day=day(price=9.8, vwap=10.0), params=prm,
|
|
|
|
|
|
is_last_day=False, quota=2000, urgent=True)
|
|
|
|
|
|
assert r["action"] == et.ACT_FIRE and r["forced"] is True, r
|
|
|
|
|
|
# 跌停一字板照旧顺延 (紧急也卖不出去, 别把单堆在封单后面空耗)
|
|
|
|
|
|
r = et.decide(side="sell", now="10:30",
|
|
|
|
|
|
day=day(price=9.0, vwap=10.0, high=9.0, low=9.0,
|
|
|
|
|
|
day_chg_from_open=-0.1, limit_down=True),
|
|
|
|
|
|
params=prm, is_last_day=False, quota=2000, urgent=True)
|
|
|
|
|
|
assert r["action"] == et.ACT_SKIP, r
|
|
|
|
|
|
# 配额出完照旧等 (紧急不越过配额纪律)
|
|
|
|
|
|
r = et.decide(side="sell", now="10:30", day=day(price=9.8, vwap=10.0), params=prm,
|
|
|
|
|
|
is_last_day=False, quota=2000, fired_today=2000, urgent=True)
|
|
|
|
|
|
assert r["action"] == et.ACT_WAIT, r
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("分桶收口·桶时点解析与累计应出量补齐")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
assert et.parse_bucket_times("11:30,14:00") == [690, 840]
|
|
|
|
|
|
assert et.parse_bucket_times(" 14:00 , 11:30 ,") == [690, 840]
|
|
|
|
|
|
assert et.parse_bucket_times("") == [] and et.parse_bucket_times(None) == []
|
|
|
|
|
|
assert et.parse_bucket_times("09:30,15:00,abc") == [] # 开收盘边界与坏值都不收
|
|
|
|
|
|
prm = {**PRM, "sell_bucket_times": "11:30,14:00"}
|
|
|
|
|
|
# 桶未到 (10:30) 且价低于均价 → 仍是等待 (桶内保留择价条件)
|
|
|
|
|
|
r = et.decide(side="sell", now="10:30", day=day(price=9.8, vwap=10.0), params=prm,
|
|
|
|
|
|
is_last_day=False, quota=3000)
|
|
|
|
|
|
assert r["action"] == et.ACT_WAIT, r
|
|
|
|
|
|
# 第一桶 (11:30) 已过、一股没出 → 补齐 3000×1/3=1000 股, 不置 forced
|
|
|
|
|
|
r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=prm,
|
|
|
|
|
|
is_last_day=False, quota=3000, fired_today=0)
|
|
|
|
|
|
assert r["action"] == et.ACT_FIRE and r["qty_hint"] == 1000, r
|
|
|
|
|
|
assert r["forced"] is False and "分桶收口" in r["reason"], r
|
|
|
|
|
|
# 桶内已按择价卖过 1000 → 第一桶份额已够, 继续等更好的价
|
|
|
|
|
|
r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=prm,
|
|
|
|
|
|
is_last_day=False, quota=3000, fired_today=1000)
|
|
|
|
|
|
assert r["action"] == et.ACT_WAIT, r
|
|
|
|
|
|
# 第二桶 (14:00) 已过、只出了 1000 → 应出 2000, 补齐 1000
|
|
|
|
|
|
r = et.decide(side="sell", now="14:05", day=day(price=9.8, vwap=10.0), params=prm,
|
|
|
|
|
|
is_last_day=False, quota=3000, fired_today=1000)
|
|
|
|
|
|
assert r["action"] == et.ACT_FIRE and r["qty_hint"] == 1000, r
|
|
|
|
|
|
# 差额不足一手 → 留给下一桶或兜底, 不发零股卖单 (规则闸会拦)
|
|
|
|
|
|
r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=prm,
|
|
|
|
|
|
is_last_day=False, quota=250, fired_today=0)
|
|
|
|
|
|
assert r["action"] == et.ACT_WAIT, r
|
|
|
|
|
|
# 14:45 兜底优先于分桶 (兜底管收尾, 桶只管中途)
|
|
|
|
|
|
r = et.decide(side="sell", now="14:45", day=day(price=9.8, vwap=10.0), params=prm,
|
|
|
|
|
|
is_last_day=False, quota=3000, fired_today=2000)
|
|
|
|
|
|
assert r["action"] == et.ACT_FIRE and r["forced"] is True, r
|
|
|
|
|
|
# 不配桶参数 = 旧行为: 13:05 价低于均价一路等到兜底
|
|
|
|
|
|
r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=PRM,
|
|
|
|
|
|
is_last_day=False, quota=3000, fired_today=0)
|
|
|
|
|
|
assert r["action"] == et.ACT_WAIT, r
|
|
|
|
|
|
# 站上均价时照旧全量择价卖出, 不受桶份额限制 (价好就多卖)
|
|
|
|
|
|
r = et.decide(side="sell", now="13:05", day=day(price=10.2, vwap=10.0), params=prm,
|
|
|
|
|
|
is_last_day=False, quota=3000, fired_today=1000)
|
|
|
|
|
|
assert r["action"] == et.ACT_FIRE and r["qty_hint"] == 2000, r
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 09:10:07 +08:00
|
|
|
|
# ================================================================ rule_gate
|
|
|
|
|
|
def ctx(**kw):
|
|
|
|
|
|
d = {"ts_code": "600000.SH",
|
|
|
|
|
|
"position": {"total_qty": 6000, "avail_qty": 6000, "frozen_reason": "NONE"},
|
|
|
|
|
|
"day": {"price": 10.0, "vwap": 10.0, "ma5": 10.0, "day_chg_from_open": 0.01},
|
|
|
|
|
|
"params": {"no_chase_ma5": 0.06, "buy_halt_dayup": 0.05},
|
|
|
|
|
|
"flags": {"buy_halt": False, "exec_halt": False, "brake_active": False,
|
|
|
|
|
|
"blacklisted": False, "is_command": False},
|
|
|
|
|
|
"caps": dict(scale=2_000_000, portfolio_cap=0.60, stock_cap=0.08, max_names=15,
|
|
|
|
|
|
portfolio_mv=800_000, names_count=5, stock_mv=60_000, is_new_name=False,
|
|
|
|
|
|
sector=None, sector_names=0, sector_mv=0.0, sector_max_names=4,
|
|
|
|
|
|
sector_max_ratio=0.40, cash_reserve=0.0, sector_source_ready=True)}
|
|
|
|
|
|
for k, v in kw.items():
|
|
|
|
|
|
if isinstance(v, dict) and isinstance(d.get(k), dict):
|
|
|
|
|
|
d[k] = {**d[k], **v}
|
|
|
|
|
|
else:
|
|
|
|
|
|
d[k] = v
|
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("规则闸·卖出正常放行 + 硬数字留痕")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
r = rg.check(side="sell", action="TRIM", qty=2000, price=10.0, ctx=ctx())
|
|
|
|
|
|
assert r["passed"] and r["failed"] == [], r
|
|
|
|
|
|
assert r["hard_numbers"]["avail_qty"] == 6000 and r["hard_numbers"]["qty"] == 2000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("规则闸·卖出超持仓/超可卖/零股非清仓 三种拦截")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
r = rg.check(side="sell", action="EXIT", qty=9000, price=10.0, ctx=ctx())
|
|
|
|
|
|
assert any(x.startswith("OVER_SELL") for x in r["failed"]), r
|
|
|
|
|
|
r = rg.check(side="sell", action="TRIM", qty=5000, price=10.0,
|
|
|
|
|
|
ctx=ctx(position={"avail_qty": 3000}))
|
|
|
|
|
|
assert any(x.startswith("T1_UNAVAILABLE") for x in r["failed"]), r
|
|
|
|
|
|
r = rg.check(side="sell", action="TRIM", qty=1050, price=10.0, ctx=ctx())
|
|
|
|
|
|
assert any(x.startswith("LOT_INVALID") for x in r["failed"]), r
|
|
|
|
|
|
# 清仓允许零股
|
|
|
|
|
|
r = rg.check(side="sell", action="EXIT", qty=6050, price=10.0,
|
|
|
|
|
|
ctx=ctx(position={"total_qty": 6050, "avail_qty": 6050}))
|
|
|
|
|
|
assert r["passed"], r
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("规则闸·减持不受冻结/刹车/上限影响 (只挡增持)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
c = ctx(position={"frozen_reason": "COMMAND_HALT"},
|
|
|
|
|
|
flags={"brake_active": True, "buy_halt": True})
|
|
|
|
|
|
r = rg.check(side="sell", action="EXIT", qty=6000, price=10.0, ctx=c)
|
|
|
|
|
|
assert r["passed"], r
|
|
|
|
|
|
r2 = rg.check(side="buy", action="ADD", qty=1000, price=10.0, ctx=c)
|
|
|
|
|
|
assert not r2["passed"] and any(x.startswith("FROZEN") for x in r2["failed"]), r2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("规则闸·买入 全局暂停/黑名单/非整百 拦截")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
r = rg.check(side="buy", action="ADD", qty=1000, price=10.0,
|
|
|
|
|
|
ctx=ctx(flags={"buy_halt": True}))
|
|
|
|
|
|
assert any(x.startswith("BUY_HALT") for x in r["failed"]), r
|
|
|
|
|
|
r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
|
|
|
|
|
|
ctx=ctx(flags={"blacklisted": True}))
|
|
|
|
|
|
assert any(x.startswith("BLACKLIST") for x in r["failed"]), r
|
|
|
|
|
|
r = rg.check(side="buy", action="OPEN", qty=150, price=10.0, ctx=ctx())
|
|
|
|
|
|
assert any(x.startswith("LOT_INVALID") for x in r["failed"]), r
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 11:27:43 +08:00
|
|
|
|
@case("规则闸·昨夜定性形态恶化 → 买入拦截, 卖出不受影响, 定性一律留痕")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
# 2026-08-05 补这道闸的实机由来: 600841 昨夜定性 SELL、已被候选层请出池子, 而在途的
|
|
|
|
|
|
# 建仓指令照样在等买入区间 —— 候选层的结论当时对执行层完全不可见。
|
|
|
|
|
|
for sig in ("SELL", "AVOID", "DROPPED", " sell "):
|
|
|
|
|
|
r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
|
|
|
|
|
|
ctx=ctx(flags={"y_signal": sig}))
|
|
|
|
|
|
assert any(x.startswith("BAD_SIGNAL") for x in r["failed"]), (sig, r)
|
|
|
|
|
|
assert r["hard_numbers"]["y_signal"] == sig.strip().upper(), (sig, r)
|
|
|
|
|
|
# 正常定性不能误伤, 但定性本身照样要写进 hard_numbers (放行的也要查得到)
|
|
|
|
|
|
ok = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
|
|
|
|
|
|
ctx=ctx(flags={"y_signal": "BUY"}))
|
|
|
|
|
|
assert ok["passed"] and ok["hard_numbers"]["y_signal"] == "BUY", ok
|
|
|
|
|
|
# 退实现B 时根本拿不到定性: 不拦, 留痕为 None (缺数据不算恶化, 与桥侧同口径)
|
|
|
|
|
|
nil = rg.check(side="buy", action="OPEN", qty=1000, price=10.0, ctx=ctx())
|
|
|
|
|
|
assert nil["passed"] and nil["hard_numbers"]["y_signal"] is None, nil
|
|
|
|
|
|
# 恶化只挡增持, 减持任何时候放行
|
|
|
|
|
|
sell = rg.check(side="sell", action="EXIT", qty=6000, price=10.0,
|
|
|
|
|
|
ctx=ctx(flags={"y_signal": "SELL"}))
|
|
|
|
|
|
assert sell["passed"], sell
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 09:10:07 +08:00
|
|
|
|
@case("规则闸·刹车对自主是拦截, 对命令只是提示 (命令至上)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
auto = rg.check(side="buy", action="ADD", qty=1000, price=10.0,
|
|
|
|
|
|
ctx=ctx(flags={"brake_active": True}))
|
|
|
|
|
|
assert not auto["passed"] and any(x.startswith("BRAKE_ACTIVE") for x in auto["failed"])
|
|
|
|
|
|
cmd = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
|
|
|
|
|
|
ctx=ctx(flags={"brake_active": True, "is_command": True}))
|
|
|
|
|
|
assert cmd["passed"], cmd
|
|
|
|
|
|
assert any(w.startswith("BRAKE_ACTIVE") for w in cmd["warnings"]), cmd
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("规则闸·不追高两道 (当日涨幅 / 距 MA5) 与 MA5 缺失告警")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
|
|
|
|
|
|
ctx=ctx(day={"day_chg_from_open": 0.07}))
|
|
|
|
|
|
assert any(x.startswith("NO_CHASE_DAYUP") for x in r["failed"]), r
|
|
|
|
|
|
r = rg.check(side="buy", action="OPEN", qty=1000, price=11.0,
|
|
|
|
|
|
ctx=ctx(day={"ma5": 10.0}))
|
|
|
|
|
|
assert any(x.startswith("NO_CHASE_MA5") for x in r["failed"]), r # 距 MA5 10% > 6%
|
|
|
|
|
|
r = rg.check(side="buy", action="OPEN", qty=1000, price=10.3,
|
|
|
|
|
|
ctx=ctx(day={"ma5": 10.0}))
|
|
|
|
|
|
assert r["passed"], r # 3% 在容忍内
|
|
|
|
|
|
r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0, ctx=ctx(day={"ma5": None}))
|
|
|
|
|
|
assert r["passed"] and any(w.startswith("MA5_MISSING") for w in r["warnings"]), r
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("规则闸·买入过组合上限 (复用 planner 同一份口径)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
r = rg.check(side="buy", action="ADD", qty=12000, price=10.0, ctx=ctx())
|
|
|
|
|
|
assert any(x.startswith("STOCK_CAP") for x in r["failed"]), r # 6万+12万 > 16万
|
|
|
|
|
|
r = rg.check(side="buy", action="ADD", qty=1000, price=10.0, ctx=ctx(caps=None))
|
|
|
|
|
|
assert any(x.startswith("CAPS_MISSING") for x in r["failed"]), r
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 13:02:51 +08:00
|
|
|
|
@case("规则闸·资金不足拒买 (真钱基准, 与 scale 上限是两个不同的约束)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
# scale 200 万、单股上限 8%=16 万 —— 上限过得了, 但账户真实可用只有 5 万
|
|
|
|
|
|
cash = {"cash_avail": 50_000.0, "cash_source": "ws"}
|
|
|
|
|
|
r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0, ctx=ctx(caps=cash))
|
|
|
|
|
|
assert r["passed"], f"1 万 < 可用 5 万, 该放行: {r}"
|
|
|
|
|
|
assert r["hard_numbers"]["cash_need"] == 10_000.0
|
|
|
|
|
|
assert r["hard_numbers"]["cash_source"] == "ws"
|
|
|
|
|
|
r = rg.check(side="buy", action="OPEN", qty=8000, price=10.0, ctx=ctx(caps=cash))
|
|
|
|
|
|
assert any(x.startswith("INSUFFICIENT_CASH") for x in r["failed"]), r
|
|
|
|
|
|
# 卖出不受资金约束 (减持方向不设门槛)
|
|
|
|
|
|
r = rg.check(side="sell", action="TRIM", qty=2000, price=10.0, ctx=ctx(caps=cash))
|
|
|
|
|
|
assert r["passed"] and "cash_need" not in r["hard_numbers"], r
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("规则闸·拿不到 ws 资金快照 → 降级不拦但留痕 (不把通道故障升级成业务停摆)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
for caps in ({"cash_source": "estimate", "cash_avail": None},
|
|
|
|
|
|
{"cash_source": "estimate", "cash_avail": 1_990_000.0}, # 估算值也不认
|
|
|
|
|
|
{}): # 字段压根没传
|
|
|
|
|
|
r = rg.check(side="buy", action="OPEN", qty=8000, price=10.0, ctx=ctx(caps=caps))
|
|
|
|
|
|
assert r["passed"], f"降级不该拦: {caps} → {r}"
|
|
|
|
|
|
assert any(w.startswith("CASH_ESTIMATED") for w in r["warnings"]), r
|
|
|
|
|
|
assert not any(x.startswith("INSUFFICIENT_CASH") for x in r["failed"]), r
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 09:10:07 +08:00
|
|
|
|
@case("规则闸·必要输入缺失与全局暂停执行一律拒绝 (宁可不动)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
r = rg.check(side="sell", action="EXIT", qty=1000, price=0, ctx=ctx())
|
|
|
|
|
|
assert any(x.startswith("PRICE_MISSING") for x in r["failed"]), r
|
|
|
|
|
|
r = rg.check(side="sell", action="EXIT", qty=0, price=10.0, ctx=ctx())
|
|
|
|
|
|
assert any(x.startswith("QTY_INVALID") for x in r["failed"]), r
|
|
|
|
|
|
r = rg.check(side="sell", action="EXIT", qty=1000, price=10.0,
|
|
|
|
|
|
ctx=ctx(flags={"exec_halt": True}))
|
|
|
|
|
|
assert any(x.startswith("EXEC_HALT") for x in r["failed"]), r
|
|
|
|
|
|
r = rg.check(side="sell", action="EXIT", qty=1000, price=10.0,
|
|
|
|
|
|
ctx=ctx(day={"halted": True}))
|
|
|
|
|
|
assert any(x.startswith("HALTED") for x in r["failed"]), r
|
|
|
|
|
|
r = rg.check(side="hold", action="X", qty=100, price=10.0, ctx=ctx())
|
|
|
|
|
|
assert any(x.startswith("SIDE_INVALID") for x in r["failed"]), r
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@case("规则闸·批量统计 (日报关注区用)")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
rs = [rg.check(side="sell", action="EXIT", qty=1000, price=10.0, ctx=ctx()),
|
|
|
|
|
|
rg.check(side="buy", action="OPEN", qty=150, price=10.0, ctx=ctx()),
|
|
|
|
|
|
rg.check(side="buy", action="OPEN", qty=100, price=10.0,
|
|
|
|
|
|
ctx=ctx(flags={"buy_halt": True}))]
|
|
|
|
|
|
s = rg.summarize(rs)
|
|
|
|
|
|
assert s["total"] == 3 and s["passed"] == 1 and s["rejected"] == 2, s
|
|
|
|
|
|
assert s["by_reason"].get("LOT_INVALID") == 1 and s["by_reason"].get("BUY_HALT") == 1, s
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 15:48:57 +08:00
|
|
|
|
@case("择时·挂单有效期: 按交易分钟推进, 跳过午休, 收盘封顶, 兜底单给到收盘")
|
|
|
|
|
|
def _():
|
|
|
|
|
|
# 普通时段: 10:00 + 10 分钟 = 10:10
|
|
|
|
|
|
assert et.slice_deadline("10:00", ttl_min=10) == et.hm_to_min("10:10")
|
|
|
|
|
|
# 跨午休: 11:25 + 10 分钟, 午休 90 分钟不计入 → 用掉上午 5 分钟, 下午再走 5 分钟 = 13:05
|
|
|
|
|
|
assert et.slice_deadline("11:25", ttl_min=10) == et.hm_to_min("13:05"), \
|
|
|
|
|
|
et._fmt(et.slice_deadline("11:25", ttl_min=10))
|
|
|
|
|
|
# 起点正落在午休里 → 从下午开盘起算
|
|
|
|
|
|
assert et.slice_deadline("12:00", ttl_min=10) == et.hm_to_min("13:10")
|
|
|
|
|
|
# 尾盘封顶, 不会算出收盘后的时点
|
|
|
|
|
|
assert et.slice_deadline("14:58", ttl_min=10) == et.hm_to_min("15:00")
|
|
|
|
|
|
# 兜底单直接给到收盘, 不受 TTL 限制 (今天必须走掉的单不能被 TTL 撤回来)
|
|
|
|
|
|
assert et.slice_deadline("14:46", ttl_min=10, forced=True) == et.hm_to_min("15:00")
|
|
|
|
|
|
assert et.slice_deadline("09:35", ttl_min=10, forced=True) == et.hm_to_min("15:00")
|
|
|
|
|
|
# 边界: 恰好到午休开始不顺延; TTL=0 即当刻失效
|
|
|
|
|
|
assert et.slice_deadline("11:20", ttl_min=10) == et.hm_to_min("11:30")
|
|
|
|
|
|
assert et.slice_deadline("10:00", ttl_min=0) == et.hm_to_min("10:00")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 09:10:07 +08:00
|
|
|
|
# ---------------------------------------------------------------- runner
|
|
|
|
|
|
def main():
|
|
|
|
|
|
passed, failed = 0, 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()
|