632 lines
28 KiB
Python
632 lines
28 KiB
Python
|
|
# -*- 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()
|