# -*- coding: utf-8 -*- """ 第十二批模块单测 (实机运行, 零外部依赖, 不连库) ================================================== 运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch12_units.py 覆盖: 自主新建仓 (动作引擎 OPEN) 的选票、数量、名额与金额的滚动扣减、行业集中度滚动; 研判请求的硬数字裁剪 (仓位数字不送决策系统); 参考位漂移的当日首答锁定与偏离即停; 研判的单轮时间预算。 约定同前: 全过输出 "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 action_engine as ae # noqa: E402 from app.services import exec_advisor as ea # noqa: E402 from app.services import judge as jd # noqa: E402 from app.services import param_store as ps # noqa: E402 RESULTS = [] def case(name): def deco(fn): RESULTS.append((name, fn)) return fn return deco # ================================================================ 夹具 def params(**kw): """action_engine 那侧要的参数快照 (口径同 param_store.sizing_params)。""" p = {"scale": 2_000_000.0, "stock_target_default": 0.06, "batch_split": (0.5, 0.25, 0.25), "min_lot_merge": True} p.update(kw) return p def caps(**kw): """portfolio.caps_ctx 的产出形状 (组合快照)。""" c = {"scale": 2_000_000.0, "portfolio_cap": 0.70, "stock_cap": 0.08, "max_names": 20, "portfolio_mv": 0.0, "names_count": 0, "stock_mv": 0.0, "is_new_name": False, "sector": None, "sector_names": 0, "sector_mv": 0.0, "sector_names_map": {}, "sector_mv_map": {}, "sector_max_names": 4, "sector_max_ratio": 0.40, "cash_reserve": 0.0, "sector_source_ready": True, "cash_avail": None, "cash_source": "estimate"} c.update(kw) return c def cand(code, price=10.0, score=100.0, sector=None, rank=1, **kw): d = {"ts_code": code, "price": price, "score": score, "sector": sector, "theme": kw.pop("theme", None), "tier": kw.pop("tier", "强传导"), "upside": kw.pop("upside", 0.3), "heat": kw.pop("heat", 0.5), "rank": rank, "bucket": "main", "src": "plan_api"} d.update(kw) return d class _PatchParams: """把 param_store 的取值函数临时换成一份字典 —— 单测不连库。""" def __init__(self, values): self.values = values self.saved = {} def __enter__(self): for name in ("get", "get_int", "get_float", "get_bool"): self.saved[name] = getattr(ps, name) v = self.values ps.get = lambda k, d=None: v.get(k, d) ps.get_int = lambda k, d=0: int(v.get(k, d)) ps.get_float = lambda k, d=0.0: float(v.get(k, d)) ps.get_bool = lambda k, d=False: bool(v.get(k, d)) return self def __exit__(self, *a): for name, fn in self.saved.items(): setattr(ps, name, fn) return False # ================================================================ 动作引擎: 新建仓 @case("新建仓·正常产出: 只提底仓批, 数量按 50% 取整到一手") def _(): # 目标 6% × 200 万 = 12 万; 底仓 50% = 6 万; 现价 10 元 → 6000 股 r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(), room_amt=1_400_000, slots=20) assert len(r["candidates"]) == 1, r c = r["candidates"][0] assert c["action"] == "OPEN" and c["side"] == "buy", c assert c["qty"] == 6000, c assert c["judge_required"] is True, "OPEN 必须有资格送研判闸" assert c["needs_user_confirm"] is False, "新建仓不强制人工确认 (档位说了算)" # 现价与行业必须挂在候选上 —— 新票在账本里没有行, 服务层取不到这两样 assert c["price"] == 10.0 and "sector" in c, c hn = c["hard_numbers"] assert hn["score"] == 100.0 and hn["price"] == 10.0, hn assert hn["target_amount"] == 120000.0, hn # 三批取整后正好 12 万 assert hn["base_amount"] == 60000.0, hn @case("新建仓·没有名额时一条不产, 且说得出为什么") def _(): r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(names_count=20), room_amt=1_400_000, slots=0) assert r["candidates"] == [], r assert "名额" in r["skipped"][0]["why"], r["skipped"] @case("新建仓·没有可投金额时一条不产, 且说得出为什么") def _(): r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(), room_amt=0, slots=20) assert r["candidates"] == [], r assert "可投金额" in r["skipped"][0]["why"], r["skipped"] @case("新建仓·取不到实时价整只跳过并留痕 (昨收不能拿来下单)") def _(): r = ae.scan_open(candidates=[cand("600000.SH", price=None)], params=params(), caps=caps(), room_amt=1_400_000, slots=20) assert r["candidates"] == [], r assert "实时价" in r["skipped"][0]["why"], r["skipped"] @case("新建仓·底仓批买不足一手 → 不提, 原因是一手不是别的") def _(): # 目标 6% × 10 万 = 6000 元; 现价 500 元一手就要 5 万 → 拆不出 r = ae.scan_open(candidates=[cand("600000.SH", price=500.0)], params=params(scale=100_000.0), caps=caps(scale=100_000.0), room_amt=70_000, slots=20) assert r["candidates"] == [], r assert "一手" in r["skipped"][0]["why"], r["skipped"] @case("新建仓·名额边走边扣: 只剩一个名额就只产一条, 其余留到下一跳") def _(): cs = [cand("600000.SH", score=300, rank=1), cand("600001.SH", score=200, rank=2), cand("600002.SH", score=100, rank=3)] r = ae.scan_open(candidates=cs, params=params(), caps=caps(names_count=19), room_amt=1_400_000, slots=1) assert [c["ts_code"] for c in r["candidates"]] == ["600000.SH"], r["candidates"] whys = [s["why"] for s in r["skipped"]] assert any("名额已用完" in w for w in whys), whys @case("新建仓·金额边走边扣: 三条候选只够两条, 第三条被金额挡下") def _(): # 每条整只目标 12 万; 可投 25 万 → 两条 (24 万) 之后只剩 1 万, 不够一整只 cs = [cand("600000.SH", score=300, rank=1), cand("600001.SH", score=200, rank=2), cand("600002.SH", score=100, rank=3)] r = ae.scan_open(candidates=cs, params=params(), caps=caps(), room_amt=250_000, slots=20) assert [c["ts_code"] for c in r["candidates"]] == ["600000.SH", "600001.SH"], r["candidates"] whys = " ".join(s["why"] for s in r["skipped"]) assert "不开半截新仓" in whys, whys @case("新建仓·钱不够一整只就不开 (不拿名额去换零头仓位)") def _(): # 目标 12 万, 只剩 5 万 —— 拆得出一手批次, 但那是个补不到目标的零头仓位 r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(), room_amt=50_000, slots=20) assert r["candidates"] == [], r assert "不开半截新仓" in r["skipped"][0]["why"], r["skipped"] @case("新建仓·滚动扣减真的生效: 组合上限只容得下两条时不会三条都放出来") def _(): # 总仓上限 70% × 200 万 = 140 万, 已有 116 万 → 只剩 24 万 = 两只 # 如果不滚动 (每条都拿同一份旧快照算), 三条会各自都通过 —— 这一例就是为它写的 cs = [cand(f"60000{i}.SH", score=300 - i, rank=i + 1) for i in range(3)] r = ae.scan_open(candidates=cs, params=params(), caps=caps(portfolio_mv=1_160_000, names_count=5), room_amt=240_000, slots=20) assert len(r["candidates"]) == 2, [c["ts_code"] for c in r["candidates"]] @case("新建仓·行业集中度也滚动: 同行业名额只剩一个时第二只被拦") def _(): cs = [cand("600000.SH", score=300, rank=1, sector="储能"), cand("600001.SH", score=200, rank=2, sector="储能")] r = ae.scan_open(candidates=cs, params=params(), caps=caps(sector_max_names=1), room_amt=1_400_000, slots=20) assert len(r["candidates"]) == 1, [c["ts_code"] for c in r["candidates"]] whys = " ".join(s["why"] for s in r["skipped"]) assert "SECTOR_NAMES" in whys, whys @case("新建仓·行业数据源没配时不误拦 (sector 一律按空处理)") def _(): cs = [cand("600000.SH", score=300, rank=1, sector="储能"), cand("600001.SH", score=200, rank=2, sector="储能")] r = ae.scan_open(candidates=cs, params=params(), caps=caps(sector_max_names=1, sector_source_ready=False), room_amt=1_400_000, slots=20) assert len(r["candidates"]) == 2, [c["ts_code"] for c in r["candidates"]] @case("新建仓·已有在途或今日被拒过的票不重复提") def _(): r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(), room_amt=1_400_000, slots=20, skip={("600000.SH", "OPEN")}) assert r["candidates"] == [], r assert "在途" in r["skipped"][0]["why"], r["skipped"] @case("跳过原因·字典形式把四种处境分得开 (在途 / 规则闸 / 研判闸)") def _(): skip = { ("600000.SH", "OPEN"): "已有在途指令 INS_20260806_600000SH_OPEN_001 (DISPATCHED)", ("600001.SH", "OPEN"): "今日已被规则闸拒过 (日切后重新评估; 拒因看 make t-gate)", ("600002.SH", "OPEN"): "今日已被研判闸驳回 (日切后重新评估; 驳回理由看 make t-gate)", } cs = [cand("600000.SH", score=300, rank=1), cand("600001.SH", score=200, rank=2), cand("600002.SH", score=100, rank=3)] r = ae.scan_open(candidates=cs, params=params(), caps=caps(), room_amt=1_400_000, slots=20, skip=skip) assert r["candidates"] == [], r got = {s["ts_code"]: s["why"] for s in r["skipped"]} assert "在途指令" in got["600000.SH"] and "DISPATCHED" in got["600000.SH"], got assert "规则闸" in got["600001.SH"] and "日切后重新评估" in got["600001.SH"], got assert "研判闸" in got["600002.SH"] and "日切后重新评估" in got["600002.SH"], got # 「明天还会不会再评估」这个问题, 光看这一行就该答得出来 assert "日切后重新评估" not in got["600000.SH"], "在途的明天照样被挡, 别写成会重来" @case("跳过原因·传集合进来仍然工作 (旧口径的向后兼容)") def _(): r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(), room_amt=1_400_000, slots=20, skip={("600000.SH", "OPEN")}) assert r["skipped"][0]["why"] == ae.SKIP_WHY_FALLBACK, r["skipped"] assert ae.skip_why({("A", "OPEN")}, ("A", "OPEN")) == ae.SKIP_WHY_FALLBACK assert ae.skip_why({("A", "OPEN")}, ("B", "OPEN")) is None assert ae.skip_why(None, ("A", "OPEN")) is None # 字典里值是空的也不能当成"没被跳过" assert ae.skip_why({("A", "OPEN"): ""}, ("A", "OPEN")) == ae.SKIP_WHY_FALLBACK @case("跳过原因·已有四类动作也跟着分得开 (scan 与 scan_open 同一套)") def _(): pos = [{"ts_code": "600000.SH", "total_qty": 1000, "avail_qty": 1000, "price": 10.0, "price_ok": True, "cushion_pct": 0.05, "cushion_peak": 0.10, "frozen_reason": "NONE", "base_qty": 1000}] skip = {("600000.SH", "TRIM"): "已有在途指令 INS_x (DISPATCHED)"} r = ae.scan(positions=pos, params=params(), market={}, skip=skip) trim = [s for s in r["skipped"] if s["action"] == "TRIM"] assert trim and "在途指令" in trim[0]["why"], r["skipped"] @case("新建仓·单票评估异常不拖垮整轮, 后面的票照常评") def _(): orig = ae.eval_open def boom(c, *a, **kw): if c["ts_code"] == "600000.SH": raise RuntimeError("求值炸了") return orig(c, *a, **kw) ae.eval_open = boom try: cs = [cand("600000.SH", score=300, rank=1), cand("600001.SH", score=200, rank=2)] r = ae.scan_open(candidates=cs, params=params(), caps=caps(), room_amt=1_400_000, slots=20) finally: ae.eval_open = orig assert [c["ts_code"] for c in r["candidates"]] == ["600001.SH"], r["candidates"] whys = " ".join(s["why"] for s in r["skipped"]) assert "评估异常" in whys and "RuntimeError" in whys, whys @case("转多信号·插队排最前: 分数低但有信号的票抢在前面") def _(): cs = [cand("600000.SH", score=300, rank=1), cand("600001.SH", score=200, rank=2), dict(cand("600002.SH", score=100, rank=3), sig_buy={"reason": "盘中判转多"})] r = ae.scan_open(candidates=cs, params=params(), caps=caps(names_count=19), room_amt=1_400_000, slots=1) assert [c["ts_code"] for c in r["candidates"]] == ["600002.SH"], r["candidates"] c = r["candidates"][0] assert c["hard_numbers"]["intraday_buy_signal"] is True, c["hard_numbers"] assert c["hard_numbers"]["intraday_buy_reason"] == "盘中判转多" assert "转多" in c["reason"], c["reason"] @case("转多信号·开关关掉就退回纯分数排序") def _(): cs = [cand("600000.SH", score=300, rank=1), dict(cand("600002.SH", score=100, rank=3), sig_buy={"reason": "盘中判转多"})] r = ae.scan_open(candidates=cs, params=params(open_signal_priority=False), caps=caps(names_count=19), room_amt=1_400_000, slots=1) assert [c["ts_code"] for c in r["candidates"]] == ["600000.SH"], r["candidates"] @case("转多信号·只改先后不改资格: 没信号的票照样能建, 有信号也过同样的闸") def _(): # 有信号但组合没名额 —— 一样建不了, 信号不是免死金牌 r = ae.scan_open(candidates=[dict(cand("600002.SH"), sig_buy={"reason": "转多"})], params=params(), caps=caps(names_count=20), room_amt=1_400_000, slots=0) assert r["candidates"] == [], r # 没信号的票照常建 r2 = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(), room_amt=1_400_000, slots=20) assert len(r2["candidates"]) == 1 assert r2["candidates"][0]["hard_numbers"]["intraday_buy_signal"] is False @case("信号消化·未持仓票的买入信号也要留痕 (从前它连账本都没有)") def _(): from app.core import signal_rules as sr sig = {"source": "intraday", "ts_code": "600002.SH", "action": "BUY", "confidence": 0.95, "suggested_price": 12.3, "reason": "放量突破"} d = sr.digest(sig, None, {"sell_conf_min": 0.75, "auto_exit_conf": 0.85}) assert d["action"] == sr.ACT_NOTE_BUY, d assert d["qty"] == 0, "留痕不产生任何数量" assert "无持仓" in d["reason"] and "只留痕" in d["reason"], d["reason"] assert d["hard_numbers"]["suggested_price"] == 12.3 @case("信号消化·持仓票的买入信号同样走留痕, 且标明有持仓") def _(): from app.core import signal_rules as sr sig = {"source": "intraday", "ts_code": "600000.SH", "action": "BUY", "confidence": 0.95} d = sr.digest(sig, {"total_qty": 1000}, {}) assert d["action"] == sr.ACT_NOTE_BUY and d["hard_numbers"]["held"] == 1000, d assert "有持仓" in d["reason"], d["reason"] @case("信号消化·卖出与 HOLD 的口径一个字没变") def _(): from app.core import signal_rules as sr prm = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3} # HOLD 仍走老的 RECORD d = sr.digest({"source": "intraday", "ts_code": "600000.SH", "action": "HOLD"}, None, prm) assert d["action"] == sr.ACT_RECORD, d # 高置信卖出仍是清仓 d = sr.digest({"source": "risk_sell", "ts_code": "600000.SH", "action": "SELL", "confidence": 0.9}, {"total_qty": 1000, "avail_qty": 1000}, prm) assert d["action"] == sr.ACT_EXIT and d["qty"] == 1000, d # 未持有的卖出信号仍然忽略 d = sr.digest({"source": "risk_sell", "ts_code": "600000.SH", "action": "SELL", "confidence": 0.9}, None, prm) assert d["action"] == sr.ACT_IGNORE, d @case("研判请求·转多信号是定性材料, 要送过去; 仓位数字照旧不送") def _(): hard = {"price": 10.0, "score": 300.0, "intraday_buy_signal": True, "intraday_buy_reason": "放量突破", "target_amount": 120000.0, "names_before": 3} got = jd._judge_hard_numbers("OPEN", hard) assert got["intraday_buy_signal"] is True and got["intraday_buy_reason"] == "放量突破" assert "target_amount" not in got and "names_before" not in got, got @case("回归·既有四类动作的判据与研判范围没被动过") def _(): assert ae.JUDGE_ACTIONS == {"FILL", "ADD", "DCA", "OPEN"}, ae.JUDGE_ACTIONS assert "TRIM" not in ae.JUDGE_ACTIONS, "减持永远不送研判" assert [a for a, _ in ae.EVALUATORS] == ["TRIM", "ADD", "FILL", "DCA"] # 持仓那条扫描的输入与产出一个字没改: 空持仓进去, 空候选出来, 不会去碰候选池 r = ae.scan(positions=[], params=params(), market={}) assert r == {"candidates": [], "skipped": []}, r # ================================================================ 研判请求的硬数字裁剪 @case("研判请求·新建仓只送定性材料, 仓位数字一个不送") def _(): hard = {"price": 10.0, "score": 300.0, "theme": "储能", "tier": "强传导", "upside": 0.3, "heat": 0.5, "plan_rank": 1, "plan_bucket": "main", "plan_src": "plan_api", "sector": "电力设备", # 下面这些是仓位口径, 决策系统本来就不管仓位 "target_pct": 0.06, "target_amount": 120000.0, "base_amount": 60000.0, "batch_scheme": "0.5,0.25,0.25", "names_before": 3, "max_names": 20, "room_amt_before": 1_400_000.0} got = jd._judge_hard_numbers("OPEN", hard) assert set(got) <= set(jd.OPEN_JUDGE_KEYS), got assert set(got) == {k for k in hard if k in jd.OPEN_JUDGE_KEYS}, got for k in ("target_amount", "names_before", "max_names", "room_amt_before", "batch_scheme", "base_amount", "target_pct"): assert k not in got, f"{k} 不该送到决策系统" assert got["score"] == 300.0 and got["price"] == 10.0, got @case("研判请求·加仓类原样送 (它们的硬数字本身就是判据)") def _(): hard = {"cushion_pct": -0.085, "stage": 1, "base_qty": 1000} for act in ("FILL", "ADD", "DCA", "SWITCH"): assert jd._judge_hard_numbers(act, hard) == hard, act assert jd._judge_hard_numbers("DCA", None) == {} @case("研判请求·数据库类型不许把请求体炸掉 (Decimal / 日期)") def _(): import datetime as _dt from decimal import Decimal payload = { "qty": 600, "hard_numbers": {"price": Decimal("34.75"), "score": 268.5}, "context": { # 这就是 2026-08-06 的现场: 评审账本流水里的 price 取自 DECIMAL 列 "recent_ledger": [{"at": "2026-08-06 14:08:35", "price": Decimal("10.02"), "verdict": "REJECT"}], "position": {"opened_date": _dt.date(2026, 8, 4), "updated_at": _dt.datetime(2026, 8, 6, 14, 8, 35)}, }, } got = jd.jsonable(payload) assert got["hard_numbers"]["price"] == 34.75 assert isinstance(got["hard_numbers"]["price"], float) assert isinstance(got["context"]["recent_ledger"][0]["price"], float) assert got["context"]["position"]["opened_date"] == "2026-08-04" assert got["context"]["position"]["updated_at"].startswith("2026-08-06 14:08:35") # 换完之后必须真的能序列化 —— 这一条才是这个用例的重点 import json as _json _json.dumps(got, ensure_ascii=False) # 其余类型原样不动 assert jd.jsonable({"a": 1, "b": "x", "c": None, "d": True}) == \ {"a": 1, "b": "x", "c": None, "d": True} @case("研判请求·账本流水里的价格就地转成 float (不靠出口那一道兜)") def _(): import inspect from app.services import proposal_service as prs src = inspect.getsource(prs._recent_ledger) assert 'float(r.get("price_at") or 0)' in src, \ "_recent_ledger 必须把 price_at 转成 float, 否则日志与留痕里带的是 Decimal" # ================================================================ 参考位漂移 @case("参考位漂移·只管新建仓, 别的动作一律不比对") def _(): with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}): prog = {"ref_lock": {"ymd": 20260806, "support": 31.27, "pressure": 40.0}} adv = {"support": 29.00, "pressure": 40.0} assert ea._check_ref_drift(action="FILL", prog=prog, adv=adv, today=20260806) == "" assert ea._check_ref_drift(action="ADD", prog=prog, adv=adv, today=20260806) == "" assert ea._check_ref_drift(action=None, prog=prog, adv=adv, today=20260806) == "" @case("参考位漂移·当日首答只锁定不比对") def _(): with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}): prog = {} adv = {"support": 31.27, "pressure": 40.0, "buy_band": [30.96, 33.89], "consulted_at": "09:35"} assert ea._check_ref_drift(action="OPEN", prog=prog, adv=adv, today=20260806) == "" assert prog["ref_lock"]["ymd"] == 20260806 assert prog["ref_lock"]["support"] == 31.27 assert prog["ref_lock"]["at"] == "09:35" @case("参考位漂移·没漂过阈值就放行") def _(): with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}): prog = {"ref_lock": {"ymd": 20260806, "support": 31.27, "pressure": 40.0}} adv = {"support": 31.50, "pressure": 40.0} # 差 0.7% assert ea._check_ref_drift(action="OPEN", prog=prog, adv=adv, today=20260806) == "" @case("参考位漂移·漂过阈值就报出来 (002335 实证: 支撑 31.27→29.00)") def _(): with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}): prog = {"ref_lock": {"ymd": 20260806, "support": 31.27, "pressure": 40.0, "at": "09:35"}} adv = {"support": 29.00, "pressure": 40.0} why = ea._check_ref_drift(action="OPEN", prog=prog, adv=adv, today=20260806) assert why, "漂了 7.3% 必须报出来" assert "31.27" in why and "29.0" in why, why assert "当日暂停" in why, why @case("参考位漂移·压力位单独漂也算 (000035 实证: 压力 5.2→5.15 未过阈值)") def _(): with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}): prog = {"ref_lock": {"ymd": 20260806, "support": 5.0, "pressure": 5.2}} assert ea._check_ref_drift(action="OPEN", prog=prog, adv={"support": 5.0, "pressure": 5.15}, today=20260806) == "" # 差 0.96%, 不到阈值 why = ea._check_ref_drift(action="OPEN", prog=prog, adv={"support": 5.0, "pressure": 4.5}, today=20260806) # 差 13.5% assert "压力" in why, why @case("参考位漂移·锁按日重置, 次日的新结论不算漂移") def _(): with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}): prog = {"ref_lock": {"ymd": 20260806, "support": 31.27, "pressure": 40.0}} adv = {"support": 29.00, "pressure": 40.0, "consulted_at": "09:31"} assert ea._check_ref_drift(action="OPEN", prog=prog, adv=adv, today=20260807) == "" assert prog["ref_lock"]["ymd"] == 20260807, "次日首答应该重新锁定" assert prog["ref_lock"]["support"] == 29.00 @case("参考位漂移·阈值设成 0 等于关掉这道防护") def _(): with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.0}): prog = {"ref_lock": {"ymd": 20260806, "support": 31.27, "pressure": 40.0}} adv = {"support": 1.0, "pressure": 40.0} assert ea._check_ref_drift(action="OPEN", prog=prog, adv=adv, today=20260806) == "" @case("参考位漂移·拿不到支撑压力时不锁也不误报") def _(): with _PatchParams({"PMS_OPEN_REF_DRIFT_MAX": 0.03}): prog = {} assert ea._check_ref_drift(action="OPEN", prog=prog, adv={"support": None, "pressure": None}, today=20260806) == "" assert "ref_lock" not in prog, "没有位就不该锁一份空的进去" # ================================================================ 窗口末日的强制完成 @case("窗口末日·命令驱动照旧强制完成 (用户下过命令, 到期必须完成)") def _(): from app.core import exec_timing as et prm = {"buy_halt_dayup": 0.05, "eod_force_time": "14:45", "eod_force_discount": 0.998} d = {"price": 10.0, "day_chg_from_open": 0.01, "bars": 60} r = et.hard_gate(side="buy", now="14:46", day=d, params=prm, is_last_day=True, is_command=True, quota=1000) assert r["action"] == et.ACT_FIRE and r["forced"] is True, r assert r["limit_price"] == 10.02, r # 10 × 1.002 @case("窗口末日·自主买入不强制追买, 到期作废 (与 window_verdict 的口径对齐)") def _(): from app.core import exec_timing as et prm = {"buy_halt_dayup": 0.05, "eod_force_time": "14:45", "eod_force_discount": 0.998} d = {"price": 10.0, "day_chg_from_open": 0.01, "bars": 60} r = et.hard_gate(side="buy", now="14:46", day=d, params=prm, is_last_day=True, is_command=False, quota=1000) assert r["action"] == et.ACT_WAIT and not r["forced"], r assert "不追买" in r["reason"] and "到期作废" in r["reason"], r["reason"] # 与窗口收口那侧说的是同一件事: 自主类到期作废 assert et.window_verdict(remaining_qty=500, tdays_left=0, is_command=False)["verdict"] == "EXPIRED" @case("窗口末日·非末日两侧行为一致 (都是顺延次日)") def _(): from app.core import exec_timing as et prm = {"buy_halt_dayup": 0.05, "eod_force_time": "14:45", "eod_force_discount": 0.998} d = {"price": 10.0, "day_chg_from_open": 0.01, "bars": 60} for is_cmd in (True, False): r = et.hard_gate(side="buy", now="14:46", day=d, params=prm, is_last_day=False, is_command=is_cmd, quota=1000) assert r["action"] == et.ACT_WAIT and "顺延次日" in r["reason"], (is_cmd, r) @case("窗口末日·卖出侧一个字没改: 两种来源都照常兜底 (宁可买不上, 不能卖不掉)") def _(): from app.core import exec_timing as et prm = {"sell_avoid_open_min": 30, "eod_force_time": "14:45", "eod_force_discount": 0.998} d = {"price": 10.0, "day_chg_from_open": -0.01, "bars": 60} for is_cmd in (True, False): r = et.hard_gate(side="sell", now="14:46", day=d, params=prm, is_last_day=True, is_command=is_cmd, quota=1000) assert r["action"] == et.ACT_FIRE and r["forced"] is True, (is_cmd, r) assert r["limit_price"] == 9.98, r # 10 × 0.998 @case("窗口末日·hard_gate 的 is_command 是必填的 (漏传立刻炸, 不许静默按某一侧走)") def _(): from app.core import exec_timing as et try: et.hard_gate(side="buy", now="14:46", day={"price": 10.0}, params={}, is_last_day=True, quota=1000) except TypeError: pass else: raise AssertionError("漏传 is_command 必须抛 TypeError") @case("窗口末日·executor 真的把 is_command 传下去了 (签名对得上)") def _(): import inspect from app.services import exec_advisor as ea from app.core import exec_timing as et assert "is_command" in inspect.signature(ea.decide).parameters assert "is_command" in inspect.signature(et.hard_gate).parameters assert "is_command" in inspect.signature(et.decide).parameters src = inspect.getsource(__import__("app.services.executor", fromlist=["x"]).run_tick) assert "is_command=bool(prog.get(\"is_command\"))" in src, \ "executor.run_tick 必须显式把指令的 is_command 传给择时, 否则这道分岔形同虚设" # ================================================================ 研判的单轮时间预算 @case("研判预算·没设预算就一律放行") def _(): from app.services import proposal_service as prs assert prs._judge_budget_left(None) is True @case("研判预算·够一次就放行, 不够就留到下一跳") def _(): import time from app.services import proposal_service as prs with _PatchParams({"PMS_JUDGE_TIMEOUT": 90}): assert prs._judge_budget_left(time.monotonic() + 150) is True # 150 > 90 assert prs._judge_budget_left(time.monotonic() + 30) is False # 30 < 90 assert prs._judge_budget_left(time.monotonic() - 1) is False # 已经超了 # ================================================================ 跑 def main(): passed, failed = 0, [] for name, fn in RESULTS: try: fn() passed += 1 print(f" ✓ {name}") except Exception as e: failed.append((name, e)) print(f" ✗ {name}: {type(e).__name__}: {e}") traceback.print_exc() print() if failed: print(f"FAILED {len(failed)}/{len(RESULTS)}") sys.exit(1) print(f"ALL PASS ({passed} cases)") if __name__ == "__main__": main()