tradingSystem/scripts/test_batch11_units.py

452 lines
22 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 -*-
"""
第十一批模块单测: 择时实现A (委托决策系统, 待办 #9)
================================================================
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch11_units.py
覆盖:
[A] 硬闸拆分后与实现B的等价性 (hard_gate 是从 decide 里拆出来的, 不许拆漂)
[B] apply_advice: 决策系统应答 → 本地决策的折算与建议价保护
[C] exec_advisor: 档位短路 / 硬闸先行 / 咨询 / 缓存TTL / 失败冷却 / UNAVAILABLE 退B
[D] 参数校验与 run_tick 全链路 (实现A出手、退B出手, 内存桩)
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
这批的三条纪律 (与四条项目铁律对齐):
* 拿不到不等于有答案 —— 咨询失败/对端给不出结论, 一律整轮退实现B, 绝不把「拿不到」当 FIRE/WAIT。
* 配额与收尾兜底始终留在 PMS 本地 —— 本地检查命中时连咨询都不发生 (用 HTTP 计数器钉死)。
* 默认档位 B 必须与接通前一字不差 —— 实现A是加装不是改装。
"""
import os
import sys
import traceback
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from app.core import exec_timing as et # 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,
"advice_limit_band": 0.03}
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
def _dec(side="buy", now="10:30", d=None, quota=1000, fired=0, last=False, cmd=True):
# cmd 默认 True = 命令口径 = 2026-08-06 之前的行为, 所以下面这批等价性用例的判据没变。
# 那天给窗口末日的强制完成分了岔 (命令照旧强制完成, 自主到期作废), hard_gate 的
# is_command 是**必填**的 —— 漏传立刻 TypeError, 不许静默按某一侧走, 所以这里补上。
# 分岔本身的行为在 test_batch12_units.py 的「窗口末日」那组里锁着。
return dict(side=side, now=now, day=d or day(), params=PRM,
is_last_day=last, is_command=cmd, fired_today=fired, quota=quota)
# ================================================================
# [A] 硬闸拆分后的等价性
# ================================================================
@case("[A1] 硬闸命中的场景: hard_gate 与 decide 逐字段一致 (拆分不许拆漂)")
def _():
scenarios = [
_dec(d=day(price=0)), # 无价
_dec(d=day(halted=True)), # 停牌
_dec(quota=1000, fired=1000), # 配额尽
_dec(now="09:10"), # 非时段
_dec(now="12:00"), # 午休
_dec(side="sell", d=day(limit_down=True)), # 跌停一字板
_dec(side="buy", d=day(limit_up=True)), # 涨停一字板
_dec(side="buy", d=day(day_chg_from_open=0.06)), # 不追高 STOP
_dec(side="sell", now="14:50"), # 卖出兜底
_dec(side="buy", now="14:50", last=True), # 买入末日兜底
_dec(side="buy", now="14:50", last=False), # 兜底后不新开买单
_dec(side="hold"), # 非法方向
]
for kw in scenarios:
h = et.hard_gate(**kw)
d = et.decide(**kw)
assert h is not None, f"该场景硬闸必须有决定: {kw}"
assert h == d, f"硬闸与 decide 不一致: {kw}\n hard={h}\n decide={d}"
@case("[A2] 看法区间 (盘中非兜底、无一字板): hard_gate 放行为 None, decide 给出看法")
def _():
for kw in (_dec(side="buy", now="10:30"),
_dec(side="sell", now="10:30"),
_dec(side="sell", now="09:40"), # 避开开盘属看法, 不属硬闸
_dec(side="buy", now="10:30", d=day(price=10.2, vwap=10.0))):
assert et.hard_gate(**kw) is None, kw
assert et.decide(**kw)["action"] in (et.ACT_FIRE, et.ACT_WAIT), kw
@case("[A3] 卖出跌停一字板在末日不跳过 (顺延无日可顺), 两个口径同步")
def _():
kw = _dec(side="sell", d=day(limit_down=True), last=True, now="10:30")
assert et.hard_gate(**kw) is None # 末日不 SKIP → 进看法/研判
assert et.decide(**kw)["action"] in (et.ACT_FIRE, et.ACT_WAIT)
# ================================================================
# [B] apply_advice: 研判 → 决策
# ================================================================
@case("[B1] 对端答 WAIT → WAIT, 理由带 [实现A] 前缀")
def _():
r = et.apply_advice(side="buy", day=day(), params=PRM,
advice={"verdict": "WAIT", "reason": "现价高于买入区间上沿"}, left=800)
assert r["action"] == et.ACT_WAIT and r["qty_hint"] == 800
assert r["reason"].startswith("[实现A]") and "买入区间上沿" in r["reason"]
@case("[B2] 对端答 FIRE → FIRE, 应答里的建议价直接采用")
def _():
r = et.apply_advice(side="buy", day=day(price=10.0), params=PRM,
advice={"verdict": "FIRE", "limit_price": 10.05,
"reason": "现价在买入区间内"}, left=600)
assert r["action"] == et.ACT_FIRE and r["limit_price"] == 10.05, r
assert r["forced"] is False
@case("[B3] 建议价缺失/为0 → 按本地口径 (买 ×1.002 / 卖 ×0.998)")
def _():
rb = et.apply_advice(side="buy", day=day(price=10.0), params=PRM,
advice={"verdict": "FIRE"}, left=100)
assert rb["limit_price"] == round(10.0 * 1.002, 2), rb
rs = et.apply_advice(side="sell", day=day(price=10.0), params=PRM,
advice={"verdict": "FIRE", "limit_price": 0}, left=100)
assert rs["limit_price"] == round(10.0 * 0.998, 2), rs
@case("[B4] 建议价偏离现价超 band → 本地口径重定并在理由里留痕")
def _():
r = et.apply_advice(side="buy", day=day(price=10.0), params=PRM,
advice={"verdict": "FIRE", "limit_price": 12.0}, left=100)
assert r["limit_price"] == round(10.0 * 1.002, 2), r
assert "偏离现价" in r["reason"], r["reason"]
# band 可调: 放宽到 25% 后 12.0 (偏 20%) 就该被采用
r2 = et.apply_advice(side="buy", day=day(price=10.0),
params={**PRM, "advice_limit_band": 0.25},
advice={"verdict": "FIRE", "limit_price": 12.0}, left=100)
assert r2["limit_price"] == 12.0, r2
@case("[B5] verdict 无法识别 → None (拿不到不等于有答案, 由调用方退实现B)")
def _():
for v in ("", None, "MAYBE", "PASS", "REJECT"):
assert et.apply_advice(side="buy", day=day(), params=PRM,
advice={"verdict": v}, left=100) is None, v
# ================================================================
# [C] exec_advisor 服务层 (HTTP 打桩)
# ================================================================
def _advisor(params=None, resp=None, exc=None):
"""装桩并返回 (exec_advisor, calls) —— calls 记录每次 HTTP 咨询的 payload。"""
from test_wiring import install_fakes
install_fakes(prices={}, params=params or {})
from app.services import exec_advisor as ea
calls = []
def fake_post(url, payload, timeout):
calls.append({"url": url, "payload": payload, "timeout": timeout})
if exc:
raise exc
return dict(resp or {})
ea._post = fake_post
return ea, calls
def _adecide(ea, *, side="buy", now="10:30", d=None, quota=1000, fired=0,
last=False, prog=None, pos=None):
return ea.decide(side=side, action="OPEN" if side == "buy" else "TRIM",
ts_code="600000.SH", now=now, day=d or day(), params=PRM,
is_last_day=last, fired_today=fired, quota=quota,
pos=pos or {"support_ref": 9.5, "pressure_ref": 11.0,
"total_qty": 0, "avail_qty": 0},
tdays_left=3, prog=prog)
@case("[C1] 默认档位 B: 决策与 et.decide 一字不差, 零咨询 (实现A是加装不是改装)")
def _():
ea, calls = _advisor(resp={"verdict": "FIRE"})
for kw in (dict(side="buy", now="10:30"), dict(side="sell", now="10:30"),
dict(side="sell", now="14:50"), dict(side="buy", now="09:10")):
got = _adecide(ea, **kw)
src = got.pop("source")
want = et.decide(side=kw["side"], now=kw["now"], day=day(), params=PRM,
is_last_day=False, fired_today=0, quota=1000)
assert got == want, (got, want)
assert src == "B"
assert not calls, "档位 B 不该有任何 HTTP 咨询"
@case("[C2] 档位A但接口地址全空 → 仍走 B (available=False), 状态一句话说破")
def _():
ea, calls = _advisor(params={"PMS_EXEC_IMPL": "A"})
r = _adecide(ea)
assert r["source"] == "B" and not calls
st = ea.status()
assert st["available"] is False and "地址为空" in st["note"], st
@case("[C3] 档位A: FIRE 研判 → 出手, 建议价采用, 结论写入 prog.exec_advice")
def _():
ea, calls = _advisor(params={"PMS_EXEC_IMPL": "A", "PMS_JUDGE_API_BASE": "http://b"},
resp={"verdict": "FIRE", "limit_price": 10.02,
"reason": "现价在买入区间内, 限价挂区间上沿", "valid_min": 6,
"confidence": 70})
prog = {}
r = _adecide(ea, prog=prog)
assert r["action"] == et.ACT_FIRE and r["limit_price"] == 10.02, r
assert r["source"] == "A" and "[实现A]" in r["reason"]
assert len(calls) == 1 and calls[0]["url"] == "http://b/api/intraday/pms_exec"
adv = prog["exec_advice"]
assert adv["verdict"] == "FIRE" and adv["ymd"] and adv["valid_until_min"], adv
# valid_min=6 < TTL 10 → 有效期按 6 分钟算
assert adv["valid_until_min"] == et.add_trade_minutes(et.hm_to_min("10:30"), 6), adv
@case("[C4] 有效期内复用缓存不再咨询 (source=A缓存), 过期后重新咨询")
def _():
ea, calls = _advisor(params={"PMS_EXEC_IMPL": "A", "PMS_JUDGE_API_BASE": "http://b"},
resp={"verdict": "WAIT", "reason": "等回踩", "valid_min": 5})
prog = {}
r1 = _adecide(ea, now="10:30", prog=prog)
assert r1["action"] == et.ACT_WAIT and r1["source"] == "A" and len(calls) == 1
r2 = _adecide(ea, now="10:33", prog=prog) # 5 分钟内
assert r2["source"] == "A缓存" and len(calls) == 1, (r2, len(calls))
r3 = _adecide(ea, now="10:36", prog=prog) # 过期
assert r3["source"] == "A" and len(calls) == 2, (r3, len(calls))
@case("[C5] 缓存是昨天的 → 当天首跳直接重新咨询 (隔日不吃旧结论)")
def _():
from app.core import tradedays as td
ea, calls = _advisor(params={"PMS_EXEC_IMPL": "A", "PMS_JUDGE_API_BASE": "http://b"},
resp={"verdict": "WAIT", "reason": "x"})
prog = {"exec_advice": {"ymd": td.ymd() - 1, "verdict": "FIRE",
"valid_until_min": 24 * 60, "reason": "昨日结论"}}
r = _adecide(ea, prog=prog)
assert len(calls) == 1 and r["source"] == "A", (len(calls), r)
assert prog["exec_advice"]["ymd"] == td.ymd()
@case("[C6] 咨询异常 → 本轮退实现B并进入冷却, 冷却内不再咨询")
def _():
ea, calls = _advisor(params={"PMS_EXEC_IMPL": "A", "PMS_JUDGE_API_BASE": "http://b"},
exc=RuntimeError("connect refused"))
prog = {}
r1 = _adecide(ea, now="10:30", prog=prog)
assert r1["source"].startswith("B(实现A不可用"), r1
assert "connect refused" in r1["source"]
assert len(calls) == 1
b = et.decide(side="buy", now="10:30", day=day(), params=PRM,
is_last_day=False, fired_today=0, quota=1000)
assert r1["action"] == b["action"] and r1["reason"] == b["reason"] # 真在走B
assert prog["exec_advice"]["fail_until_min"], prog
r2 = _adecide(ea, now="10:32", prog=prog) # 冷却 (默认5分钟) 内
assert len(calls) == 1 and "冷却至" in r2["source"], (len(calls), r2)
r3 = _adecide(ea, now="10:40", prog=prog) # 冷却过后恢复咨询
assert len(calls) == 2, len(calls)
@case("[C7] 对端回 UNAVAILABLE (给不出结论) → 退B + 冷却, 不当 FIRE 也不当 WAIT")
def _():
ea, calls = _advisor(params={"PMS_EXEC_IMPL": "A", "PMS_JUDGE_API_BASE": "http://b"},
resp={"verdict": "UNAVAILABLE", "reason": "昨夜结论缺失, 请退内置择时"})
prog = {}
r = _adecide(ea, prog=prog)
assert r["source"].startswith("B(实现A不可用: UNAVAILABLE"), r
assert "UNAVAILABLE" in prog["exec_advice"]["error"], prog
@case("[C8] 本地检查先行: 配额尽/兜底/不追高时零咨询 (这些永远不问决策系统)")
def _():
ea, calls = _advisor(params={"PMS_EXEC_IMPL": "A", "PMS_JUDGE_API_BASE": "http://b"},
resp={"verdict": "WAIT", "reason": "决策系统说等"})
r1 = _adecide(ea, quota=1000, fired=1000) # 配额尽
assert r1["action"] == et.ACT_WAIT and r1["source"] == "guard" and not calls
r2 = _adecide(ea, side="sell", now="14:50") # 兜底: 对端说什么都不算
assert r2["action"] == et.ACT_FIRE and r2["forced"] and r2["source"] == "guard"
assert not calls
r3 = _adecide(ea, side="buy", d=day(day_chg_from_open=0.08)) # 不追高
assert r3["action"] == et.ACT_STOP and r3["source"] == "guard" and not calls
@case("[C9] valid_min 只缩不放: 对端给 999 分钟按本端 TTL 上限截")
def _():
ea, calls = _advisor(params={"PMS_EXEC_IMPL": "A", "PMS_JUDGE_API_BASE": "http://b",
"PMS_EXEC_ADVICE_TTL_MIN": "10"},
resp={"verdict": "WAIT", "reason": "x", "valid_min": 999})
prog = {}
_adecide(ea, now="10:30", prog=prog)
assert prog["exec_advice"]["valid_until_min"] == \
et.add_trade_minutes(et.hm_to_min("10:30"), 10), prog
@case("[C10] prog=None (试算语义): 咨询照发但不缓存, 两跳两问")
def _():
ea, calls = _advisor(params={"PMS_EXEC_IMPL": "A", "PMS_JUDGE_API_BASE": "http://b"},
resp={"verdict": "WAIT", "reason": "x"})
_adecide(ea, prog=None)
_adecide(ea, prog=None)
assert len(calls) == 2, len(calls)
@case("[C11] 咨询 payload 带齐现场: 行情快照/参考位/持仓/窗口 (bionic 靠它免重取)")
def _():
ea, calls = _advisor(params={"PMS_EXEC_IMPL": "A", "PMS_JUDGE_API_BASE": "http://b"},
resp={"verdict": "WAIT", "reason": "x"})
_adecide(ea, side="sell", now="10:31",
pos={"support_ref": 9.5, "pressure_ref": 11.0, "stop_ref": 9.2,
"ref_source": "bionic", "total_qty": 3000, "avail_qty": 3000,
"avg_cost": 9.0, "cushion_pct": 0.11})
p = calls[0]["payload"]
assert p["direction"] == "PMS_EXEC" and p["side"] == "sell"
assert p["day"]["price"] == 10.0 and p["day"]["vwap"] == 10.0
assert p["refs"]["support"] == 9.5 and p["refs"]["pressure"] == 11.0
assert p["position"]["avg_cost"] == 9.0 and p["qty_left"] == 1000
assert p["now"] == "10:31" and p["tdays_left"] == 3
@case("[C12] PMS_EXEC_API_BASE 独立配置时优先于 PMS_JUDGE_API_BASE")
def _():
ea, calls = _advisor(params={"PMS_EXEC_IMPL": "A", "PMS_JUDGE_API_BASE": "http://judge",
"PMS_EXEC_API_BASE": "http://exec"},
resp={"verdict": "WAIT", "reason": "x"})
_adecide(ea, prog={})
assert calls[0]["url"].startswith("http://exec/"), calls[0]["url"]
# ================================================================
# [D] 参数校验 + run_tick 全链路
# ================================================================
@case("[D1] PMS_EXEC_IMPL 只认 A/B (大小写归一), 其他值被参数中心拒绝")
def _():
from test_wiring import install_fakes
install_fakes(prices={})
from app.services import param_store
assert param_store.set_param("PMS_EXEC_IMPL", "A")["ok"]
assert param_store.set_param("PMS_EXEC_IMPL", "b")["ok"] # 归一后合法
bad = param_store.set_param("PMS_EXEC_IMPL", "C")
assert bad["ok"] is False and "只能是" in bad["error"], bad
from app.services import exec_advisor as ea
param_store.set_param("PMS_EXEC_IMPL", "a")
assert ea.impl() == "A"
@case("[D2] run_tick·实现A出手全链路: 研判FIRE → 规则闸 → 下发, exec_advice 落表")
def _():
from test_wiring import install_fakes
fake = install_fakes(prices={"600000.SH": 10.0},
params={"PMS_TOTAL_SCALE": "2000000", "PMS_EXEC_IMPL": "A",
"PMS_JUDGE_API_BASE": "http://b"},
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
"avail_qty": 6000, "avg_cost": 9.0}])
from app.services import exec_advisor as ea, executor
calls = []
def fake_post(url, payload, timeout):
calls.append(payload)
return {"verdict": "FIRE", "limit_price": 9.99, "reason": "现价在卖出区间内, 放行分批卖出",
"valid_min": 10, "confidence": 75}
ea._post = fake_post
fake.insert_instruction(instruction_id="INS_A", origin_type="plan", origin_id="P1",
ts_code="600000.SH", action="TRIM", side="sell", qty=3000,
window_tdays=3, status="PROPOSED",
progress={"deadline": "2026-07-29", "is_command": True,
"children": []})
r = executor.run_tick(now=datetime(2026, 7, 27, 10, 5))
assert r["ok"] and len(r["fired"]) == 1, r
assert r["fired"][0]["limit"] == 9.99 # 建议价被采用
assert "[实现A]" in r["fired"][0]["reason"]
ins = fake.instructions["INS_A"]
assert ins["status"] == "DISPATCHED"
assert ins["progress"]["exec_advice"]["verdict"] == "FIRE" # 结论随落表持久化
assert len(calls) == 1
# 同日第二跳: 配额已出完 → 硬闸 WAIT, 不再咨询
r2 = executor.run_tick(now=datetime(2026, 7, 27, 10, 6))
assert r2["waited"] and len(calls) == 1, (r2, len(calls))
@case("[D3] run_tick·实现A故障不停出手: 咨询挂了照走实现B, 单照下")
def _():
from test_wiring import install_fakes
fake = install_fakes(prices={"600000.SH": 10.0},
params={"PMS_TOTAL_SCALE": "2000000", "PMS_EXEC_IMPL": "A",
"PMS_JUDGE_API_BASE": "http://b"},
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
"avail_qty": 6000, "avg_cost": 9.0}])
from app.services import exec_advisor as ea, executor
def dead_post(url, payload, timeout):
raise RuntimeError("bionic down")
ea._post = dead_post
fake.insert_instruction(instruction_id="INS_F", origin_type="plan", origin_id="P1",
ts_code="600000.SH", action="TRIM", side="sell", qty=3000,
window_tdays=3, status="PROPOSED",
progress={"deadline": "2026-07-29", "is_command": True,
"children": []})
# 10:05 价==vwap → 实现B本来就该出手; 实现A故障不能把这一单卡住
r = executor.run_tick(now=datetime(2026, 7, 27, 10, 5))
assert r["ok"] and len(r["fired"]) == 1, r
ins = fake.instructions["INS_F"]
assert ins["status"] == "DISPATCHED"
assert ins["progress"]["exec_advice"]["error"], ins["progress"] # 冷却已记
assert "实现A不可用" in ins["progress"]["last_decision"]["source"]
@case("[D4] run_tick·默认档位B下 exec_advice 不出现 (不接通就没有新状态)")
def _():
from test_wiring import install_fakes
fake = install_fakes(prices={"600000.SH": 10.0},
params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
"avail_qty": 6000, "avg_cost": 9.0}])
from app.services import executor
fake.insert_instruction(instruction_id="INS_B0", origin_type="plan", origin_id="P1",
ts_code="600000.SH", action="TRIM", side="sell", qty=3000,
window_tdays=3, status="PROPOSED",
progress={"deadline": "2026-07-29", "is_command": True,
"children": []})
r = executor.run_tick(now=datetime(2026, 7, 27, 10, 5))
assert r["ok"] and r["fired"], r
assert "exec_advice" not in fake.instructions["INS_B0"]["progress"]
assert fake.instructions["INS_B0"]["progress"]["last_decision"]["source"] == "B"
# ---------------------------------------------------------------- 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()