# -*- 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())