tradingSystem/scripts/test_batch37_units.py

422 lines
18 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
第三十七批模块单测 (一字板方向按昨收定, 2026-10-08, 零外部依赖)
================================================================
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch37_units.py
病根: executor._limit_up / _limit_down 拿 day_chg_from_open 的正负定一字板方向。这个涨幅是
现价比窗口首根分钟线的开盘价, 一字板上两者相等, 涨幅恒为 0, 同时满足「>= 0 算涨停」与
「<= 0 算跌停」, 两个标记一起亮。于是一字跌停的买单以「涨停一字板」跳过, 一字涨停的卖单
在非末日被当跌停顺延, 规则闸还给卖单挂上跌停告警。这两个函数此前没有任何单测钉着。
本批覆盖:
A et.limit_board 纯函数: 一字涨停/一字跌停/正常 K 线/三个板块的限制/风险警示 5%/
低价股取整/除权除息日落在带外回退/昨收缺失与旧口径逐字相同/涨跌幅过小回退。
B 接线 (executor._limit_flags 加三处消费方): 一字跌停买入跳过且理由说对、规则闸拦
LIMIT_DOWN 不拦 LIMIT_UP; 一字涨停卖出非末日不再顺延、规则闸不挂跌停告警;
正常 K 线不查昨收; 昨收缺失或取数异常与修之前逐字相同; 网格即时成交两个方向。
C market.get_prev_close 新鲜度: 只认上一交易日那一根/隔夜没跑回 None/今天的行不算/
三种日期格式/取数异常不缓存/长假后第一天。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
from datetime import date, 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.core import rule_gate as rg # noqa: E402
from app.core import tradedays as td # noqa: E402
from app.services import exec_advisor, executor, market # 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, "urgent_sell_discount": 0.995, "sell_bucket_times": ""}
def one_word(px, bars=30):
"""一字板的当日聚合: 最高 = 最低 = 开盘 = 现价, 于是 day_chg_from_open 恰为 0。"""
return {"price": px, "open": px, "high": px, "low": px, "vwap": px,
"day_chg_from_open": 0.0, "bars": bars}
def normal(px=10.3):
return {"price": px, "open": 10.0, "high": 10.5, "low": 9.9, "vwap": 10.2,
"day_chg_from_open": px / 10.0 - 1, "bars": 60}
def _old_up(day):
"""修之前 executor._limit_up 的原文, 拿来比「回退时逐字相同」。"""
if not day:
return False
hi, lo, px = day.get("high"), day.get("low"), day.get("price")
chg = day.get("day_chg_from_open")
return bool(hi and lo and px and hi == lo == px and (chg or 0) >= 0 and day.get("bars", 0) > 3)
def _old_down(day):
if not day:
return False
hi, lo, px = day.get("high"), day.get("low"), day.get("price")
chg = day.get("day_chg_from_open")
return bool(hi and lo and px and hi == lo == px and (chg or 0) <= 0 and day.get("bars", 0) > 3)
def _flags(b):
return (b["limit_up"], b["limit_down"], b["basis"])
# ================================================================ A 纯函数
@case("A 一字涨停 (主板 +10%) → 只亮涨停, 按昨收定")
def _():
assert _flags(et.limit_board(one_word(11.0), prev_close=10.0, ts_code="600000.SH")) \
== (True, False, "prev_close")
@case("A 一字跌停 (主板 -10%) → 只亮跌停, 按昨收定 (旧口径两个都亮, 这就是病根)")
def _():
d = one_word(9.0)
assert _old_up(d) and _old_down(d), "旧口径在一字板上两个都亮 —— 若这条不成立说明病根描述错了"
assert _flags(et.limit_board(d, prev_close=10.0, ts_code="600000.SH")) \
== (False, True, "prev_close")
@case("A 正常 K 线 (最高≠最低) → 两个都不亮, 给了昨收也一样")
def _():
for pc in (None, 10.0, 9.0):
assert _flags(et.limit_board(normal(), prev_close=pc, ts_code="600000.SH")) \
== (False, False, "none")
# 三根以内的分钟线不认一字板 (开盘头几分钟没成交时价格一条线, 不是封板)
assert _flags(et.limit_board(one_word(11.0, bars=3), prev_close=10.0)) == (False, False, "none")
assert _flags(et.limit_board({}, prev_close=10.0)) == (False, False, "none")
assert _flags(et.limit_board(None, prev_close=10.0)) == (False, False, "none")
@case("A 板块限制: 创业板/科创板 20%, 北交所 30%, 主板 10%")
def _():
assert et.board_limit_ratio("600000.SH") == 0.10
assert et.board_limit_ratio("000001.SZ") == 0.10
assert et.board_limit_ratio("300750.SZ") == 0.20
assert et.board_limit_ratio("301001.SZ") == 0.20
assert et.board_limit_ratio("688981.SH") == 0.20
assert et.board_limit_ratio("830799.BJ") == 0.30
assert _flags(et.limit_board(one_word(120.0), prev_close=100.0, ts_code="300750.SZ"))[:2] \
== (True, False)
assert _flags(et.limit_board(one_word(80.0), prev_close=100.0, ts_code="688981.SH"))[:2] \
== (False, True)
assert _flags(et.limit_board(one_word(13.0), prev_close=10.0, ts_code="830799.BJ"))[:2] \
== (True, False)
# 同样 +20% 放在主板上不可能是一字板, 落在带外 → 方向不明回退
assert _flags(et.limit_board(one_word(12.0), prev_close=10.0, ts_code="600000.SH"))[2] \
== "fallback"
@case("A 风险警示 5% 与低价股取整都认得出")
def _():
assert _flags(et.limit_board(one_word(10.5), prev_close=10.0, ts_code="600000.SH"))[:2] \
== (True, False)
assert _flags(et.limit_board(one_word(9.5), prev_close=10.0, ts_code="600000.SH"))[:2] \
== (False, True)
# 1.05 × 1.1 = 1.155, 交易所四舍五入到 1.16, 实际涨幅 +10.48%, 仍在上沿 11% 之内
assert _flags(et.limit_board(one_word(1.16), prev_close=1.05, ts_code="600000.SH"))[:2] \
== (True, False)
# 1.05 × 0.95 = 0.9975 → 1.00, 实际 -4.76%, 仍在下沿 4% 之外
assert _flags(et.limit_board(one_word(1.0), prev_close=1.05, ts_code="600000.SH"))[:2] \
== (False, True)
@case("A 除权除息日: 小额分红仍判得对, 大比例送转落在带外回退")
def _():
# 昨收 10, 每股派 0.3, 除息参考价 9.7, 涨停 10.67: 比未折算的昨收 +6.7%, 仍在带里 → 涨停
assert _flags(et.limit_board(one_word(10.67), prev_close=10.0, ts_code="600000.SH"))[:2] \
== (True, False)
# 10 送 3: 参考价 7.69, 涨停 8.46, 比未折算的昨收 -15.4%。主板跌停不可能跌这么多 → 回退,
# 而不是把一字涨停判成跌停
assert _flags(et.limit_board(one_word(8.46), prev_close=10.0, ts_code="600000.SH")) \
== (True, True, "fallback")
# 10 转 10: 涨停 5.5, -45%, 哪个板块都不可能 → 回退
assert _flags(et.limit_board(one_word(5.5), prev_close=10.0, ts_code="300750.SZ"))[2] \
== "fallback"
@case("A 昨收缺失/为零/认不出 → 回退, 两个标记与旧口径逐字相同")
def _():
days = [one_word(11.0), one_word(9.0), one_word(11.0, bars=3), normal(), {},
{**one_word(11.0), "day_chg_from_open": None},
{**one_word(11.0), "day_chg_from_open": 0.02},
{**one_word(11.0), "day_chg_from_open": -0.02},
{**one_word(11.0), "high": 0}]
for pc in (None, 0, "", "abc", -1):
for d in days:
b = et.limit_board(d, prev_close=pc, ts_code="600000.SH")
assert (b["limit_up"], b["limit_down"]) == (_old_up(d), _old_down(d)), (pc, d, b)
@case("A 涨跌幅过小 (停牌行情冻在昨收、或一条线但没封板) → 回退旧口径")
def _():
for px in (10.0, 10.1, 9.9, 10.39):
assert _flags(et.limit_board(one_word(px), prev_close=10.0, ts_code="600000.SH")) \
== (True, True, "fallback"), px
# ================================================================ B 接线
class _Stub:
"""临时替换 market.get_prev_close; 退出时还原。raise_on_call 用来证明「不该查」。"""
def __init__(self, value=None, exc=None, raise_on_call=False):
self.value, self.exc, self.raise_on_call, self.calls = value, exc, raise_on_call, []
def __call__(self, code, *a, **k):
self.calls.append(code)
if self.raise_on_call:
raise AssertionError(f"不该为 {code} 取昨收")
if self.exc:
raise self.exc
return self.value
def __enter__(self):
self._orig = market.get_prev_close
market.get_prev_close = self
return self
def __exit__(self, *a):
market.get_prev_close = self._orig
def _ctx_day(code, day):
up, down = executor._limit_flags(code, day)
return {**day, "limit_up": up, "limit_down": down, "halted": False}
def _gate(side, day_ctx, qty=1000):
ctx = {"ts_code": "600000.SH",
"position": {"total_qty": 6000, "avail_qty": 6000, "frozen_reason": "NONE"},
"day": {**day_ctx, "ma5": day_ctx["price"]},
"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": True},
"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)}
return rg.check(side=side, action="ADD" if side == "buy" else "TRIM", qty=qty,
price=day_ctx["price"], ctx=ctx)
def _codes(lst):
return {x.split(":")[0] for x in lst}
@case("B 一字跌停·买入: 跳过且理由是跌停不是涨停; 规则闸拦 LIMIT_DOWN 不拦 LIMIT_UP")
def _():
with _Stub(value=10.0) as s:
dc = _ctx_day("600000.SH", one_word(9.0))
assert s.calls == ["600000.SH"]
assert (dc["limit_up"], dc["limit_down"]) == (False, True)
r = et.decide(side="buy", now="10:30", day=dc, params=PRM, is_last_day=False, quota=1000)
assert r["action"] == et.ACT_SKIP and "跌停一字板" in r["reason"] \
and "涨停" not in r["reason"], r
# 末日 14:50 也不接 (修之前同样是跳过, 只是理由错)
r = et.decide(side="buy", now="14:50", day=dc, params=PRM, is_last_day=True, quota=1000)
assert r["action"] == et.ACT_SKIP and "跌停一字板" in r["reason"], r
kw = dict(side="buy", now="10:30", day=dc, params=PRM, is_last_day=False,
is_command=True, quota=1000)
assert et.hard_gate(**kw) == et.decide(**kw) # 硬闸与 decide 不拆漂
g = _gate("buy", dc)
assert not g["passed"] and "LIMIT_DOWN" in _codes(g["failed"]) \
and "LIMIT_UP" not in _codes(g["failed"]), g
@case("B 一字涨停·卖出: 非末日不再当跌停顺延, 规则闸不挂跌停告警; 买入照旧跳过")
def _():
with _Stub(value=10.0):
dc = _ctx_day("600000.SH", one_word(11.0))
assert (dc["limit_up"], dc["limit_down"]) == (True, False)
r = et.decide(side="sell", now="10:30", day=dc, params=PRM, is_last_day=False, quota=1000)
assert r["action"] == et.ACT_FIRE, r # 现价 = 均价, 过了开盘半小时, 出手
r = et.decide(side="sell", now="10:30", day=dc, params=PRM, is_last_day=False, quota=1000,
urgent=True)
assert r["action"] == et.ACT_FIRE and "紧急" in r["reason"], r
g = _gate("sell", dc)
assert g["passed"] and "LIMIT_DOWN" not in _codes(g["warnings"]), g
r = et.decide(side="buy", now="10:30", day=dc, params=PRM, is_last_day=False, quota=1000)
assert r["action"] == et.ACT_SKIP and "涨停一字板" in r["reason"], r
assert "LIMIT_UP" in _codes(_gate("buy", dc)["failed"])
@case("B 正常 K 线: 两个标记都不亮, 而且根本不去取昨收 (平常这一跳不多查库)")
def _():
with _Stub(raise_on_call=True) as s:
assert executor._limit_flags("600000.SH", normal()) == (False, False)
assert executor._limit_flags("600000.SH", {}) == (False, False)
assert s.calls == []
@case("B 昨收缺失或取数异常: 两个都亮, 买卖的结局与修之前逐字相同")
def _():
for stub in (_Stub(value=None), _Stub(exc=RuntimeError("库抖"))):
with stub:
dc = _ctx_day("600000.SH", one_word(9.0))
assert (dc["limit_up"], dc["limit_down"]) == (True, True)
old = {**one_word(9.0), "limit_up": True, "limit_down": True, "halted": False}
for side in ("buy", "sell"):
kw = dict(side=side, now="10:30", params=PRM, is_last_day=False, quota=1000)
assert et.decide(day=dc, **kw) == et.decide(day=old, **kw)
r = et.decide(side="buy", now="10:30", day=dc, params=PRM, is_last_day=False, quota=1000)
assert r["action"] == et.ACT_SKIP and "涨停一字板" in r["reason"], r # 旧理由原样
g = _gate("buy", dc)
assert _codes(g["failed"]) & {"LIMIT_UP", "LIMIT_DOWN"} == {"LIMIT_UP"}, g # 只记一条
@case("B 网格即时成交: 一字跌停不接买, 一字涨停可卖")
def _():
with _Stub(value=10.0):
down = _ctx_day("600000.SH", one_word(9.0))
up = _ctx_day("600000.SH", one_word(11.0))
now = datetime(2026, 9, 16, 10, 30)
r = exec_advisor._strategy_immediate(side="buy", now=now, day=down, quota=1000, fired_today=0)
assert r["action"] == et.ACT_WAIT and "跌停" in r["reason"], r
r = exec_advisor._strategy_immediate(side="sell", now=now, day=up, quota=1000, fired_today=0)
assert r["action"] == et.ACT_FIRE, r
# ================================================================ C 昨收新鲜度
class _Rows:
def __init__(self, rows=None, exc=None):
self.rows, self.exc, self.calls = rows or [], exc, 0
def __call__(self, ts_code, days=70):
self.calls += 1
if self.exc:
raise self.exc
return list(self.rows)
def _with_rows(fake, fn):
orig = market._factor_rows
market._factor_rows = fake
market._ma_cache.update({"day": None, "data": {}})
try:
return fn()
finally:
market._factor_rows = orig
market._ma_cache.update({"day": None, "data": {}})
TODAY = date(2026, 9, 16) # 周三, 前后都不是节假日, 不受日历库有没有当年数据影响
PREV = date(2026, 9, 15)
@case("C 最近一根正是上一交易日 → 返回它的收盘")
def _():
f = _Rows([{"trade_date": date(2026, 9, 14), "close_qfq": 9.8},
{"trade_date": PREV, "close_qfq": 10.0}])
assert _with_rows(f, lambda: market.get_prev_close("600000.SH", today=TODAY)) == 10.0
@case("C 因子桥隔夜没跑 (最近一根是前天) → None, 不拿隔天的收盘判方向")
def _():
f = _Rows([{"trade_date": date(2026, 9, 11), "close_qfq": 9.6},
{"trade_date": date(2026, 9, 14), "close_qfq": 9.8}])
assert _with_rows(f, lambda: market.get_prev_close("600000.SH", today=TODAY)) is None
@case("C 表里已有今天的行 → 不算, 仍取上一交易日那根")
def _():
f = _Rows([{"trade_date": PREV, "close_qfq": 10.0},
{"trade_date": TODAY, "close_qfq": 11.0}])
assert _with_rows(f, lambda: market.get_prev_close("600000.SH", today=TODAY)) == 10.0
@case("C 日期列几种写法都认: date / datetime / 横线串 / 八位串")
def _():
for td_val in (PREV, "2026-09-15", "20260915", datetime(2026, 9, 15, 0, 0)):
f = _Rows([{"trade_date": td_val, "close_qfq": 10.0}])
assert _with_rows(f, lambda: market.get_prev_close("600000.SH", today=TODAY)) == 10.0, td_val
@case("C 没有行 → None; 取数异常 → None 且不缓存, 下一次重查")
def _():
assert _with_rows(_Rows([]), lambda: market.get_prev_close("600000.SH", today=TODAY)) is None
f = _Rows(exc=RuntimeError("代理库抖"))
def run():
a = market.get_prev_close("600000.SH", today=TODAY)
f.exc, f.rows = None, [{"trade_date": PREV, "close_qfq": 10.0}]
b = market.get_prev_close("600000.SH", today=TODAY)
c = market.get_prev_close("600000.SH", today=TODAY) # 这次走缓存
return a, b, c
assert _with_rows(f, run) == (None, 10.0, 10.0)
assert f.calls == 2, f.calls
@case("C 长假后第一天 (10-08 接 09-30): 日历有当年数据时认得出")
def _():
today = date(2026, 10, 8)
f = _Rows([{"trade_date": date(2026, 9, 30), "close_qfq": 10.0}])
got = _with_rows(f, lambda: market.get_prev_close("600000.SH", today=today))
if td.calendar_degraded(today):
# 日历退化成周一到周五, 上一交易日算成 10-07, 对不上 → None, 一字板回退旧口径 (守成)
assert got is None, got
print(" (本机日历缺 2026 年数据, 走的是退化分支)")
else:
assert td.prev_trade_day(today) == date(2026, 9, 30)
assert got == 10.0, got
@case("C 暂无昨收或日期陈旧不缓存,数据恢复后同日可重新识别")
def _():
for rows in ([], [{"trade_date": date(2026, 9, 14), "close_qfq": 9.0}]):
f = _Rows(rows)
def run():
assert market.get_prev_close("600000.SH", today=TODAY) is None
f.rows = [{"trade_date": PREV, "close_qfq": 10.0}]
assert market.get_prev_close("600000.SH", today=TODAY) == 10.0
assert market.get_prev_close("600000.SH", today=TODAY) == 10.0
_with_rows(f, run)
assert f.calls == 2
@case("C 非正数与非有限昨收不缓存,不作为方向依据")
def _():
for value in (0, -10, float("nan"), float("inf")):
f = _Rows([{"trade_date": PREV, "close_qfq": value}])
def run():
assert market.get_prev_close("600000.SH", today=TODAY) is None
f.rows = [{"trade_date": PREV, "close_qfq": 10.0}]
assert market.get_prev_close("600000.SH", today=TODAY) == 10.0
_with_rows(f, run)
assert f.calls == 2
def main():
ok = 0
for name, fn in RESULTS:
try:
fn()
ok += 1
print(" ok " + name)
except Exception:
print(" FAIL " + name)
traceback.print_exc()
print("-" * 60)
if ok == len(RESULTS):
print("ALL PASS (%d cases)" % ok)
return 0
print("FAILED %d/%d" % (len(RESULTS) - ok, len(RESULTS)))
return 1
if __name__ == "__main__":
sys.exit(main())