tradingSystem/scripts/test_batch14_units.py

632 lines
28 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 -*-
"""
第十四批模块单测 (宏观择时层, 零外部依赖)
==========================================
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch14_units.py
覆盖 (MACRO_TIMING_PLAN.md V3; 参数依据 MACRO_CALIB_2026-08-18.md):
* 指数计算: 四步公式对照手算值 / 汇率 +1 自然日 as-of 对齐 / 样本不足与停更守卫;
* 区域判定: 进出迟滞 / 带内横跳不切区 / 不可用与恢复;
* 周期与对数映射: streak 与峰值推进 / 同深度不重复加码 / 首步保底 / 封顶 / 周期清零;
* 分方向触发: 偏冷进区即动 / 偏热回落再动按峰值深度 / 直接翻区不动作;
* 让路: 在途 / 冷却 / 暂停买入 / 刹车 / 地板天花板;
* 服务层 (打桩): 全自动下命令与冲突让路 / 建议与采纳幂等 / 闸状态读写与陈旧失效 /
proposal_service 的买入侧过滤留 TRIM。
"""
import json
import math
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import macro_rules as mr # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
P = {"hot_th": 25.0, "cold_th": -20.0, "exit_band": 15.0, "confirm_days": 1,
"trigger_hot": "zone_exit", "trigger_cold": "zone_enter",
"log_s0": 0.049, "log_k": 1.6, "shift_max": 0.20, "step_min": 0.02,
"min_pct": 0.20, "max_pct": 0.0, "respect_brake": True,
"ret_win": 20, "z_win": 40, "beta": 0.02, "stale_tdays": 3}
F0 = {"portfolio_pct": 0.50, "portfolio_cap": 0.60, "buy_halt": False,
"brake_active": False, "inflight": False,
"cooldown_left_reduce": 0, "cooldown_left_increase": 0}
# ================================================================ 计算
def _mkdays(n, start=20240102):
"""生成 n 个工作日 ymd (跳过周末, 够单测用)。"""
from datetime import date, timedelta
d = date(2024, 1, 2)
out = []
while len(out) < n:
if d.weekday() < 5:
out.append(int(d.strftime("%Y%m%d")))
d += timedelta(days=1)
return out
@case("计算·四步公式对照手算值 (常数汇率利率, 股指恒定日涨)")
def _():
days = _mkdays(70)
zs = [(d, 3000.0 * math.exp(0.001 * i)) for i, d in enumerate(days)]
fx = [(d, 7.2) for d in days]
sh = [(d, 1.9) for d in days]
r = mr.compute_hedge_index({"zs": zs, "fx": fx, "shibor": sh}, P, days[-1])
# 股 20 日收益恒 0.02, 汇 0, 利率变化 0 → spread_adj 恒 0.02 → 标准差为零 → 不可用
assert not r["ok"] and "标准差" in r["reason"], r
@case("计算·有波动时值与手算 z 一致")
def _():
days = _mkdays(75)
zs, px = [], 3000.0
for i, d in enumerate(days):
px *= math.exp(0.002 if i % 2 == 0 else -0.001)
zs.append((d, px))
fx = [(d, 7.2 + (0.01 if i % 3 == 0 else 0.0)) for i, d in enumerate(days)]
sh = [(d, 1.9) for d in days]
r = mr.compute_hedge_index({"zs": zs, "fx": fx, "shibor": sh}, P, days[-1])
assert r["ok"], r
# 手算: 复算 spread_adj 序列与末点 z×10
closes = [v for _, v in zs]
fx_al, _f = mr.asof_align(days, fx, shift_days=1)
sh_al, _s = mr.asof_align(days, sh, shift_days=0)
sr_ = mr.log_rets(closes, 20)
fr_ = mr.log_rets(fx_al, 20)
sd_ = mr.diffs(sh_al, 20)
adj = [None if (sr_[i] is None or fr_[i] is None or sd_[i] is None)
else sr_[i] + fr_[i] - 0.02 * sd_[i] for i in range(len(days))]
w = [v for v in adj[-40:] if v is not None]
m = sum(w) / len(w)
sd = math.sqrt(sum((x - m) ** 2 for x in w) / (len(w) - 1))
want = (adj[-1] - m) / sd * 10
assert abs(r["value"] - round(want, 4)) < 1e-6, (r["value"], want)
@case("计算·汇率 +1 自然日 as-of: 周一用上周五值")
def _():
tdays = [20260803, 20260804, 20260805] # 周一到周三
fx = [(20260731, 7.10), (20260803, 7.20)] # 周五 / 周一
vals, filled = mr.asof_align(tdays, fx, shift_days=1)
assert vals == [7.10, 7.20, 7.20] and filled >= 2, (vals, filled)
@case("计算·样本不足与数据停更都判不可用")
def _():
days = _mkdays(30)
zs = [(d, 3000.0 + i) for i, d in enumerate(days)]
r = mr.compute_hedge_index({"zs": zs, "fx": zs, "shibor": zs}, P, days[-1])
assert not r["ok"] and "样本不足" in r["reason"], r
days = _mkdays(70)
zs = [(d, 3000.0 * math.exp((0.002 if i % 2 else -0.001)))
for i, d in enumerate(days)]
# 末日落后今天 30 个自然日 → 停更
r = mr.compute_hedge_index({"zs": zs, "fx": zs, "shibor": zs}, P,
mr.ymd_plus_days(days[-1], 30))
assert not r["ok"] and "停更" in r["reason"], r
# ================================================================ 区域迟滞
@case("区域·进出迟滞: 26 进热, 20/16 仍在热, 14 才离区")
def _():
assert mr.zone_next(26, "NEUTRAL", P) == mr.Z_HOT
assert mr.zone_next(20, mr.Z_HOT, P) == mr.Z_HOT # 带内延续
assert mr.zone_next(16, mr.Z_HOT, P) == mr.Z_HOT
assert mr.zone_next(14, mr.Z_HOT, P) == mr.Z_NEUTRAL # 穿出退出带
assert mr.zone_next(24, mr.Z_NEUTRAL, P) == mr.Z_NEUTRAL # 未过进入阈值不再进
@case("区域·偏冷对称 + 直接翻区 + 不可用")
def _():
assert mr.zone_next(-21, "NEUTRAL", P) == mr.Z_COLD
assert mr.zone_next(-16, mr.Z_COLD, P) == mr.Z_COLD
assert mr.zone_next(-14, mr.Z_COLD, P) == mr.Z_NEUTRAL
assert mr.zone_next(-21, mr.Z_HOT, P) == mr.Z_COLD # 单日巨变直接翻
assert mr.zone_next(None, mr.Z_HOT, P) == mr.Z_UNAVAILABLE
assert mr.zone_next(26, mr.Z_UNAVAILABLE, P) == mr.Z_HOT # 恢复后按进入规则重判
# ================================================================ 周期与映射
@case("周期·streak 与峰值推进, 退区带出峰值, 换区清零")
def _():
c1, ev = mr.carry_cycle("NEUTRAL", None, mr.Z_HOT, 27.0, P, 20260801)
assert c1["streak"] == 1 and abs(c1["e_peak"] - 2.0) < 1e-9 and ev is None
c2, ev = mr.carry_cycle(mr.Z_HOT, c1, mr.Z_HOT, 31.0, P, 20260802)
assert c2["streak"] == 2 and abs(c2["e_peak"] - 6.0) < 1e-9 and ev is None
c3, ev = mr.carry_cycle(mr.Z_HOT, c2, mr.Z_NEUTRAL, 12.0, P, 20260803)
assert c3 is None and ev and abs(ev["e_peak"] - 6.0) < 1e-9
c4, ev = mr.carry_cycle(mr.Z_HOT, c2, mr.Z_COLD, -22.0, P, 20260803)
assert c4["zone"] == mr.Z_COLD and c4["streak"] == 1 and ev["zone"] == mr.Z_HOT
@case("映射·对数目标: 标定锚点与封顶")
def _():
# S0=0.049, k=1.6: e=2.9 → ≈5.1%; e=17.1 → ≈12%
assert abs(mr.log_target(2.9, P) - 0.049 * math.log(1 + 2.9 / 1.6)) < 1e-9
assert 0.049 < mr.log_target(2.9, P) < 0.053
assert 0.115 < mr.log_target(17.1, P) < 0.125
assert mr.log_target(1e9, P) == 0.20 # 封顶
assert mr.log_target(0, P) == 0.0 and mr.log_target(-3, P) == 0.0
@case("决策·偏冷进区即动: 浅进区首步保底 2%")
def _():
cyc = {"zone": mr.Z_COLD, "start": 1, "streak": 1, "e_peak": 0.5, "done_shift": 0.0}
d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
e_now=0.5, flags=dict(F0), params=P)
assert d["action"] == mr.ACT_INCREASE and abs(d["pct"] - 0.02) < 1e-9, d
@case("决策·偏冷加深才有新步, 同深度不重复加码")
def _():
# 已执行 5%, 深度对应目标 ≈5% → 无新步
e = 2.9
done = mr.log_target(e, P)
cyc = {"zone": mr.Z_COLD, "streak": 3, "e_peak": e, "done_shift": round(done, 4)}
d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
e_now=e, flags=dict(F0), params=P)
assert d["action"] is None and not d["blocked"], d
# 深度翻倍 → 目标增量超过 step_min → 出新步
d2 = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
e_now=10.0, flags=dict(F0), params=P)
assert d2["action"] == mr.ACT_INCREASE \
and abs(d2["pct"] - (mr.log_target(10.0, P) - done)) < 1e-4, d2
@case("决策·确认天数: streak 不足不动")
def _():
p2 = {**P, "confirm_days": 2}
cyc = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 3.0, "done_shift": 0.0}
d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
e_now=3.0, flags=dict(F0), params=p2)
assert d["action"] is None and not d["blocked"], d
@case("决策·偏热回落再动: 退区日按峰值深度一次动作, 当日已动过不重复")
def _():
ev = {"zone": mr.Z_HOT, "e_peak": 6.0, "done_shift": 0.0}
d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False,
e_now=0.0, flags=dict(F0), params=P)
assert d["action"] == mr.ACT_REDUCE \
and abs(d["pct"] - mr.log_target(6.0, P)) < 1e-4, d
d2 = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=True,
e_now=0.0, flags=dict(F0), params=P)
assert d2["action"] is None, d2
@case("决策·偏热在区内 (zone_exit 口径) 不动手; 直接翻冷区旧周期退出不降仓")
def _():
cyc = {"zone": mr.Z_HOT, "streak": 3, "e_peak": 8.0, "done_shift": 0.0}
d = mr.decide(zone=mr.Z_HOT, cycle=cyc, exit_event=None, exit_acted=False,
e_now=8.0, flags=dict(F0), params=P)
assert d["action"] is None and not d["blocked"], d
# HOT 直接翻 COLD: 退出事件不触发降仓 (新区自己的逻辑接管)
ev = {"zone": mr.Z_HOT, "e_peak": 8.0}
ccold = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 2.0, "done_shift": 0.0}
d2 = mr.decide(zone=mr.Z_COLD, cycle=ccold, exit_event=ev, exit_acted=False,
e_now=2.0, flags=dict(F0), params=P)
assert d2["action"] == mr.ACT_INCREASE, d2
@case("决策·偏热 zone_enter 口径 (可切): 进区步进")
def _():
p2 = {**P, "trigger_hot": "zone_enter"}
cyc = {"zone": mr.Z_HOT, "streak": 1, "e_peak": 3.0, "done_shift": 0.0}
d = mr.decide(zone=mr.Z_HOT, cycle=cyc, exit_event=None, exit_acted=False,
e_now=3.0, flags=dict(F0), params=p2)
assert d["action"] == mr.ACT_REDUCE and d["pct"] > 0, d
@case("决策·地板与天花板裁剪")
def _():
ev = {"zone": mr.Z_HOT, "e_peak": 20.0}
d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False,
e_now=0.0, flags={**F0, "portfolio_pct": 0.21}, params=P)
assert d["action"] is None and any("地板" in b for b in d["blocked"]), d
cyc = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 9.0, "done_shift": 0.0}
d2 = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
e_now=9.0, flags={**F0, "portfolio_pct": 0.595}, params=P)
assert d2["action"] is None and any("天花板" in b for b in d2["blocked"]), d2
# 天花板裁剪但仍够一步
d3 = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
e_now=9.0, flags={**F0, "portfolio_pct": 0.55}, params=P)
assert d3["action"] == mr.ACT_INCREASE and abs(d3["pct"] - 0.05) < 1e-9, d3
@case("决策·让路: 暂停买入/刹车只拦升仓, 在途与冷却双向拦")
def _():
cyc = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 5.0, "done_shift": 0.0}
d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
e_now=5.0, flags={**F0, "buy_halt": True}, params=P)
assert d["action"] is None and any("暂停买入" in b for b in d["blocked"]), d
d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
e_now=5.0, flags={**F0, "brake_active": True}, params=P)
assert d["action"] is None and any("刹车" in b for b in d["blocked"]), d
d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False,
e_now=5.0, flags={**F0, "brake_active": True},
params={**P, "respect_brake": False})
assert d["action"] == mr.ACT_INCREASE, d # 参数放开则不让
ev = {"zone": mr.Z_HOT, "e_peak": 6.0}
d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False,
e_now=0.0, flags={**F0, "buy_halt": True, "brake_active": True},
params=P)
assert d["action"] == mr.ACT_REDUCE, d # 降仓不受这两条限制
d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False,
e_now=0.0, flags={**F0, "inflight": True}, params=P)
assert d["action"] is None and any("在途" in b for b in d["blocked"]), d
d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False,
e_now=0.0, flags={**F0, "cooldown_left_reduce": 2}, params=P)
assert d["action"] is None and any("冷却" in b for b in d["blocked"]), d
# ================================================================ 服务层 (打桩)
from app.services import macro_service as ms # noqa: E402
from app.services import param_store, proposal_service # noqa: E402
from app.repo import macro_repo, pms_repo # noqa: E402
class _Env:
"""打桩环境: 参数 / 信号表 / 命令服务 / 组合快照 全部进内存。"""
def __init__(self, **params):
self.params = {"PMS_MACRO_ENABLED": True, "PMS_MACRO_AUTONOMY": "full",
"PMS_MACRO_STOCK_GATE": True, "PMS_GLOBAL_EXEC_HALT": False,
"PMS_GLOBAL_BUY_HALT": False, "PMS_BRAKE_UNTIL": 0,
"PMS_MACRO_COLD_TH": -20.0, **params}
self.rows = {} # (key, ymd) -> row
self.issued = []
self.ledger = []
self.commands = {} # command_id -> {status,...}
self.issue_result = {"ok": True, "command_id": "CMD_T_0001"}
self.saved = {}
def install(self):
env = self
self._orig = (param_store.get, param_store.get_bool, param_store.get_float,
param_store.get_int, param_store.get_list, param_store.set_param,
macro_repo.fetch_hedge_inputs, macro_repo.upsert_signal,
macro_repo.get_signal, macro_repo.recent_signals,
pms_repo.insert_ledger, pms_repo.get_command)
def _get(key, default=None):
return env.params.get(key, ms._params.__defaults__ or default) \
if key in env.params else default
param_store.get = lambda k, d=None: env.params.get(k, d)
param_store.get_bool = lambda k, d=False: bool(env.params.get(k, d))
param_store.get_float = lambda k, d=0.0: float(env.params.get(k, d))
param_store.get_int = lambda k, d=0: int(env.params.get(k, d))
param_store.get_list = lambda k, d=None, sep=",": \
[x.strip() for x in str(env.params.get(k, "") or "").split(sep) if x.strip()] \
or list(d or [])
def _setp(k, v, by="user"):
env.saved[k] = v
env.params[k] = v
return {"ok": True, "key": k, "value": v}
param_store.set_param = _setp
macro_repo.fetch_hedge_inputs = lambda limit_days=140: env.inputs
def _ups(**kw):
env.rows[(kw["signal_key"], int(kw["trade_date"]))] = {
"signal_key": kw["signal_key"], "trade_date": int(kw["trade_date"]),
"value": kw.get("value"), "zone": kw.get("zone"),
"detail": kw.get("detail") or {}, "action": kw.get("action") or "NONE",
"ref_id": kw.get("ref_id") or "", "note": kw.get("note") or ""}
return 1
macro_repo.upsert_signal = _ups
macro_repo.get_signal = lambda k, d: env.rows.get((k, int(d)))
macro_repo.recent_signals = lambda k, limit=30: sorted(
[r for (kk, _), r in env.rows.items() if kk == k],
key=lambda r: -r["trade_date"])[:limit]
pms_repo.insert_ledger = lambda **kw: env.ledger.append(kw) or 1
pms_repo.get_command = lambda cid: env.commands.get(cid)
import app.services.command_service as csvc
self._orig_issue = csvc.issue
def _issue(cmd_type, params, **kw):
env.issued.append({"cmd_type": cmd_type, "params": params, **kw})
r = dict(env.issue_result)
if r.get("ok"):
env.commands[r["command_id"]] = {"command_id": r["command_id"],
"cmd_type": cmd_type,
"status": "EXECUTING", "progress": {}}
return r
csvc.issue = _issue
import app.services.portfolio as pf
self._orig_view = pf.positions_view
pf.positions_view = lambda **kw: {"totals": {"portfolio_pct": 0.50},
"params": {"portfolio_cap": 0.60}}
ms._gate_cache["at"] = 0.0
ms._gate_cache["data"] = None
def uninstall(self):
(param_store.get, param_store.get_bool, param_store.get_float,
param_store.get_int, param_store.get_list, param_store.set_param,
macro_repo.fetch_hedge_inputs, macro_repo.upsert_signal,
macro_repo.get_signal, macro_repo.recent_signals,
pms_repo.insert_ledger, pms_repo.get_command) = self._orig
import app.services.command_service as csvc
csvc.issue = self._orig_issue
import app.services.portfolio as pf
pf.positions_view = self._orig_view
ms._gate_cache["at"] = 0.0
ms._gate_cache["data"] = None
def _cold_inputs():
"""构造一组会算出偏冷极值的序列: 末 20 日股指大跌 + 人民币升值。"""
days = _mkdays(80)
zs, px = [], 3300.0
fxs, fxv = [], 7.30
for i, d in enumerate(days):
drift = 0.001 if i % 2 == 0 else -0.0005
if i >= len(days) - 15:
drift = -0.012 # 尾段连续大跌
px *= math.exp(drift)
zs.append((d, px))
fdrift = 0.0002 if i % 3 == 0 else 0.0
if i >= len(days) - 15:
fdrift = -0.004 # 人民币升值 (USDCNH 下行)
fxv *= math.exp(fdrift)
fxs.append((d, fxv))
sh = [(d, 1.9 + (0.01 if i % 5 == 0 else 0.0)) for i, d in enumerate(days)]
return {"zs": zs, "fx": fxs, "shibor": sh}, days[-1]
@case("服务·全自动: 偏冷极值 → 下升仓命令 (署名 macro), 信号行与闸状态落表")
def _():
env = _Env(PMS_MACRO_SIGNALS="hedge_fx")
env.inputs, today = _cold_inputs()
env.install()
try:
r = ms.scan(now=__import__("datetime").datetime.strptime(str(today), "%Y%m%d"))
assert r["ok"], r
assert env.issued and env.issued[0]["cmd_type"] == "INCREASE_EXPOSURE", env.issued
assert env.issued[0]["issued_by"] == "macro"
assert 0.02 <= env.issued[0]["params"]["pct"] <= 0.20
row = env.rows[("hedge_fx", today)]
assert row["action"] == "CMD_ISSUED" and row["ref_id"] == "CMD_T_0001", row
assert row["zone"] == mr.Z_COLD and float(row["value"]) < -20
# 周期已执行量被推进 (进区口径)
assert row["detail"]["cycle"]["done_shift"] > 0, row["detail"]
# 偏冷不落闸
st = json.loads(env.saved["PMS_MACRO_GATE_STATE"])
assert st["active"] is False
# 评审账本有 PASS 留痕
assert any(x.get("verdict") == "PASS" for x in env.ledger)
finally:
env.uninstall()
@case("服务·同日重扫不重复下命令; 冲突时让路留痕")
def _():
env = _Env()
env.inputs, today = _cold_inputs()
env.install()
try:
now = __import__("datetime").datetime.strptime(str(today), "%Y%m%d")
ms.scan(now=now)
n1 = len(env.issued)
ms.scan(now=now) # 重扫: 当日已下过 → 不再下
assert len(env.issued) == n1 == 1, env.issued
# 冲突场景: 清空当日行, 命令服务回 CONFLICT
env.rows.clear()
env.issued.clear()
env.ledger.clear()
env.issue_result = {"ok": False, "errors": ["CONFLICT: 与在途命令冲突"],
"conflicts": [{"with_cmd_type": "REDUCE_EXPOSURE"}]}
r = ms.scan(now=now)
row = env.rows[("hedge_fx", today)]
assert row["action"] == "BLOCKED" and "CONFLICT" in row["note"], row
assert any(x.get("verdict") == "REJECT" for x in env.ledger)
assert r["skipped"], r
finally:
env.uninstall()
@case("服务·只出建议档: 建议落行, 采纳幂等, 二次采纳被拒")
def _():
env = _Env(PMS_MACRO_AUTONOMY="propose_only")
env.inputs, today = _cold_inputs()
env.install()
try:
now = __import__("datetime").datetime.strptime(str(today), "%Y%m%d")
ms.scan(now=now)
assert not env.issued
row = env.rows[("hedge_fx", today)]
assert row["action"] == "ADVICE_INCREASE" and row["detail"]["advice"]["pct"] > 0
# 采纳 (td.ymd() 是真实今天, 与合成日期不同 → 先把行挪到真实今天再采纳)
from app.core import tradedays as td
real_today = td.ymd()
env.rows[("hedge_fx", real_today)] = {**row, "trade_date": real_today}
r = ms.adopt("hedge_fx")
assert r.get("ok") and env.issued and env.issued[0]["issued_by"] == "user", r
r2 = ms.adopt("hedge_fx")
assert not r2.get("ok") and "不重复" in r2.get("error", ""), r2
finally:
env.uninstall()
@case("服务·偏热日落闸: 闸状态写入, gate_state 读回生效; 陈旧与关档失效")
def _():
env = _Env()
days = _mkdays(80)
# 构造偏热: 尾段股指大涨 + 人民币贬值
zs, px = [], 3000.0
fxs, fxv = [], 6.90
for i, d in enumerate(days):
drift = 0.0008 if i % 2 == 0 else -0.0006
if i >= len(days) - 12:
drift = 0.012
px *= math.exp(drift)
zs.append((d, px))
fdrift = -0.0002 if i % 3 == 0 else 0.0
if i >= len(days) - 12:
fdrift = 0.004
fxv *= math.exp(fdrift)
fxs.append((d, fxv))
env.inputs = {"zs": zs, "fx": fxs,
"shibor": [(d, 1.9 + (0.01 if i % 5 == 0 else 0.0))
for i, d in enumerate(days)]}
env.install()
try:
now = __import__("datetime").datetime.strptime(str(days[-1]), "%Y%m%d")
r = ms.scan(now=now)
row = env.rows[("hedge_fx", days[-1])]
assert row["zone"] == mr.Z_HOT, row
# zone_exit 口径: 在区内不下命令
assert not env.issued
st = json.loads(env.saved["PMS_MACRO_GATE_STATE"])
assert st["active"] is True and r["gate"]["active"] is True
# gate_state 陈旧失效: 把 ymd 改到 30 天前
env.params["PMS_MACRO_GATE_STATE"] = json.dumps(
{"active": True, "ymd": mr.ymd_plus_days(days[-1], -30)})
ms._gate_cache["at"] = 0.0
assert ms.gate_state()["active"] is False
# 关总开关立即失效
env.params["PMS_MACRO_GATE_STATE"] = json.dumps(
{"active": True, "ymd": __import__("app.core.tradedays",
fromlist=["ymd"]).ymd()})
env.params["PMS_MACRO_ENABLED"] = False
ms._gate_cache["at"] = 0.0
assert ms.gate_state()["active"] is False
finally:
env.uninstall()
@case("服务·数据不可用: 不动作不落闸, 行记 UNAVAILABLE")
def _():
env = _Env()
days = _mkdays(30)
env.inputs = {"zs": [(d, 3000.0 + i) for i, d in enumerate(days)],
"fx": [], "shibor": []}
env.install()
try:
now = __import__("datetime").datetime.strptime(str(days[-1]), "%Y%m%d")
r = ms.scan(now=now)
row = env.rows[("hedge_fx", days[-1])]
assert row["zone"] == mr.Z_UNAVAILABLE and not env.issued, row
st = json.loads(env.saved["PMS_MACRO_GATE_STATE"])
assert st["active"] is False
assert r["ok"], r
finally:
env.uninstall()
@case("闸·proposal_service 过滤: 拦买入侧留 TRIM; 读闸炸了按不拦")
def _():
# 直接验证 scan_and_route 里那段过滤逻辑等价的行为: 用 gate_state 打桩
orig = ms.gate_state
ms.gate_state = lambda: {"active": True, "why": "单测"}
try:
g = proposal_service._macro_gate()
assert g.get("active") is True
cands = [{"ts_code": "A", "action": "ADD", "side": "buy"},
{"ts_code": "B", "action": "TRIM", "side": "sell"},
{"ts_code": "C", "action": "DCA", "side": "buy"}]
kept, skipped = [], []
for c in cands: # 与 scan_and_route 中的过滤同构
(skipped if c.get("side") == "buy" else kept).append(c)
assert [c["ts_code"] for c in kept] == ["B"]
assert len(skipped) == 2
finally:
ms.gate_state = orig
# 读闸抛异常 → _macro_gate 返回空 dict (不拦)
ms.gate_state = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
try:
assert proposal_service._macro_gate() == {}
finally:
ms.gate_state = orig
@case("闸·strategy_runner 判定: 闸生效等价 buy_paused, 读不到按 False")
def _():
from app.services import strategy_runner as srun
orig = ms.gate_state
ms.gate_state = lambda: {"active": True}
try:
assert srun._macro_gate_active() is True
finally:
ms.gate_state = orig
ms.gate_state = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
try:
assert srun._macro_gate_active() is False
finally:
ms.gate_state = orig
@case("冷却·按信号历史行数计交易日距离, 零方案命令不占冷却")
def _():
hist = [{"trade_date": 20260805, "action": "NONE", "ref_id": "", "detail": {}},
{"trade_date": 20260804, "action": "NONE", "ref_id": "", "detail": {}},
{"trade_date": 20260803, "action": "CMD_ISSUED", "ref_id": "C1",
"detail": {"advice": {"direction": "REDUCE"}}}]
# C1 在途 EXECUTING
orig = pms_repo.get_command
pms_repo.get_command = lambda cid: {"command_id": cid, "status": "EXECUTING",
"cmd_type": "REDUCE_EXPOSURE"}
try:
inflight, last_red, last_inc = ms._action_history(hist)
assert inflight and last_red == 20260803 and last_inc is None
# 今天 0805 (行已在): 距 0803 过了 2 个交易日 → 冷却 3 剩 1
assert ms._cooldown_left(hist, last_red, 20260805, 3) == 1
# 今天 0806 首扫 (行未写): 距离 +1 → 剩 0
assert ms._cooldown_left(hist, last_red, 20260806, 3) == 0
# 零方案: 命令被立刻取消 → 不算生效动作
pms_repo.get_command = lambda cid: {"command_id": cid, "status": "CANCELLED",
"cmd_type": "REDUCE_EXPOSURE"}
inflight, last_red, _li = ms._action_history(hist)
assert not inflight and last_red is None
finally:
pms_repo.get_command = orig
@case("服务·休假模式与总开关: 扫描整体跳过")
def _():
env = _Env(PMS_GLOBAL_EXEC_HALT=True)
env.inputs, today = _cold_inputs()
env.install()
try:
r = ms.scan()
assert not env.issued and any("休假" in s for s in r["skipped"]), r
env.params["PMS_GLOBAL_EXEC_HALT"] = False
env.params["PMS_MACRO_ENABLED"] = False
r2 = ms.scan()
assert any("总开关" in s for s in r2["skipped"]), r2
finally:
env.uninstall()
# ================================================================ runner
def main():
passed = failed = 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()