tradingSystem/scripts/test_batch37_units.py

422 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 -*-
"""
第三十七批模块单测 (一字板方向按昨收定, 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())