tradingSystem/app/core/macro_rules.py

346 lines
16 KiB
Python
Raw Normal View History

2026-08-19 10:04:20 +08:00
# -*- coding: utf-8 -*-
"""
宏观择时 · 纯逻辑 (计算 / 区域判定 / 决策; 无外部依赖, 可单测)
================================================================
设计与参数依据: MACRO_TIMING_PLAN.md (V3) 与标定报告 MACRO_CALIB_2026-08-18.md
取数与落表在 repo/macro_repo.py, 编排与下命令在 services/macro_service.py, 本模块只算数
三块纯函数:
1. compute_hedge_index 股汇对冲指数四步计算 ( as-of 对齐与数据守卫)
2. zone_next 区域判定 (带迟滞: 进出阈值不同, 防止阈值附近横跳反复触发)
3. carry_cycle / decide 极值周期状态推进 升降仓决策 (对数映射 + 分方向触发口径)
口径钉死 (与标定脚本 scripts/calibrate_macro_signal.py 完全一致, 单测对照):
* 汇率日期先 +1 自然日再 as-of 对齐国内交易日 (当日无值沿用最近前值);
* 滚动标准化用样本标准差 (除以 n-1), 窗口内任一天缺值即该日不出值;
* 指数 = z 分数 × 10
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta
# 区域 (zone) 词表 —— 与 pms_macro_signal.zone 列一致
Z_HOT, Z_COLD, Z_NEUTRAL, Z_UNAVAILABLE = "HOT", "COLD", "NEUTRAL", "UNAVAILABLE"
# 触发口径
TRIG_ENTER, TRIG_EXIT = "zone_enter", "zone_exit"
# 动作方向
ACT_REDUCE, ACT_INCREASE = "REDUCE", "INCREASE"
# ================================================================ 日期与对齐
def norm_ymd(v):
"""任意日期形态归一为整数 YYYYMMDD; 解析不了返回 None。"""
if v is None:
return None
if hasattr(v, "strftime"):
return int(v.strftime("%Y%m%d"))
s = str(v).strip()[:10].replace("-", "").replace("/", "")
if len(s) >= 8 and s[:8].isdigit():
return int(s[:8])
return None
def ymd_plus_days(ymd: int, n: int) -> int:
d = datetime.strptime(str(ymd), "%Y%m%d").date() + timedelta(days=n)
return int(d.strftime("%Y%m%d"))
def ymd_gap_days(a: int, b: int) -> int:
"""两个 YYYYMMDD 之间差多少个自然日 (a - b)。"""
da = datetime.strptime(str(a), "%Y%m%d").date()
db = datetime.strptime(str(b), "%Y%m%d").date()
return (da - db).days
def asof_align(trade_days: list, series: list, shift_days: int = 0) -> tuple:
"""把 (ymd, value) 序列 as-of 对齐到交易日历。
shift_days 先把数据日期整体后移 N 个自然日 (汇率用 +1)对齐规则: 每个交易日取
日期不晚于它的最近一个值; 找不到任何前值的交易日置 None
返回 (对齐后的值列表, 非精确匹配而沿用前值的天数)
"""
if shift_days:
series = [(ymd_plus_days(d, shift_days), v) for d, v in series]
series.sort(key=lambda x: x[0])
vals, filled, j, last = [], 0, 0, None
for t in trade_days:
while j < len(series) and series[j][0] <= t:
last = series[j][1]
j += 1
exact = j > 0 and series[j - 1][0] == t
if last is not None and not exact:
filled += 1
vals.append(last)
return vals, filled
def log_rets(vals: list, win: int) -> list:
out = [None] * len(vals)
for i in range(win, len(vals)):
a, b = vals[i], vals[i - win]
if a and b and a > 0 and b > 0:
out[i] = math.log(a / b)
return out
def diffs(vals: list, win: int) -> list:
out = [None] * len(vals)
for i in range(win, len(vals)):
if vals[i] is not None and vals[i - win] is not None:
out[i] = vals[i] - vals[i - win]
return out
def roll_z_last(vals: list, win: int):
"""最后一个点的滚动 z 分数 ×10 (样本标准差)。窗口不满或 std 为零返回 (None, 原因)。"""
w = [v for v in vals[-win:] if v is not None]
if len(w) < win:
return None, f"标准化窗口不满 ({len(w)}/{win})"
m = sum(w) / len(w)
var = sum((x - m) ** 2 for x in w) / (len(w) - 1)
sd = math.sqrt(var)
if sd <= 1e-12:
return None, "标准差为零 (序列长期不变, 数据可疑)"
last = vals[-1]
if last is None:
return None, "当日修正值缺失"
return (last - m) / sd * 10.0, ""
# ================================================================ 指数计算
def compute_hedge_index(inputs: dict, params: dict, today_ymd: int) -> dict:
"""股汇对冲指数四步计算 + 数据守卫。
inputs: {"zs": [(ymd, close)...升序], "fx": [...], "shibor": [...]}
params: {"ret_win", "z_win", "beta", "stale_tdays"}
返回 {"ok", "ymd"(信号交易日=上证末日), "value", "detail", "reason"}
守卫不过时 ok=False, value=None 调用方按 UNAVAILABLE 处理 (不动作不落闸)
"""
ret_win = int(params.get("ret_win") or 20)
z_win = int(params.get("z_win") or 40)
beta = float(params.get("beta") or 0.02)
stale = int(params.get("stale_tdays") or 3)
zs = sorted([(norm_ymd(d), float(v)) for d, v in (inputs.get("zs") or [])
if norm_ymd(d) and v], key=lambda x: x[0])
fx = sorted([(norm_ymd(d), float(v)) for d, v in (inputs.get("fx") or [])
if norm_ymd(d) and v], key=lambda x: x[0])
sh = sorted([(norm_ymd(d), float(v)) for d, v in (inputs.get("shibor") or [])
if norm_ymd(d) and v is not None], key=lambda x: x[0])
need = ret_win + z_win + 5
if len(zs) < need:
return {"ok": False, "ymd": zs[-1][0] if zs else today_ymd, "value": None,
"detail": {}, "reason": f"上证样本不足 ({len(zs)} < {need})"}
if not fx or not sh:
return {"ok": False, "ymd": zs[-1][0], "value": None, "detail": {},
"reason": "汇率或利率序列为空"}
# 数据新鲜度: 任一源末日落后今天超过 stale 个交易日判不可用。
# 交易日距离用「自然日 ×2」宽松换算 (与 market.get_refs 同一手法), 不依赖交易日历。
ages = {"zs": ymd_gap_days(today_ymd, zs[-1][0]),
"fx": ymd_gap_days(today_ymd, fx[-1][0]),
"shibor": ymd_gap_days(today_ymd, sh[-1][0])}
worst = max(ages, key=lambda k: ages[k])
if ages[worst] > stale * 2:
return {"ok": False, "ymd": zs[-1][0], "value": None,
"detail": {"ages_days": ages},
"reason": f"数据停更: {worst} 最新 {ages[worst]} 天前 "
f"(允许 {stale} 个交易日, 按自然日×2 宽松换算)"}
tdays = [d for d, _ in zs]
close = [v for _, v in zs]
fx_al, fx_fill = asof_align(tdays, fx, shift_days=1) # 汇率 +1 自然日再 as-of
sh_al, sh_fill = asof_align(tdays, sh, shift_days=0)
sr = log_rets(close, ret_win)
fr = log_rets(fx_al, ret_win)
sd = diffs(sh_al, ret_win)
spread_adj = [None if (sr[i] is None or fr[i] is None or sd[i] is None)
else sr[i] + fr[i] - beta * sd[i] for i in range(len(tdays))]
val, why = roll_z_last(spread_adj, z_win)
if val is None:
return {"ok": False, "ymd": tdays[-1], "value": None,
"detail": {"ages_days": ages}, "reason": why}
i = len(tdays) - 1
detail = {"stock_ret": round(sr[i], 6) if sr[i] is not None else None,
"fx_ret": round(fr[i], 6) if fr[i] is not None else None,
"shibor_d20": round(sd[i], 4) if sd[i] is not None else None,
"spread_adj": round(spread_adj[i], 6),
"zs_last": tdays[-1], "fx_last": fx[-1][0], "shibor_last": sh[-1][0],
"fx_filled": fx_fill, "shibor_filled": sh_fill,
"ages_days": ages, "samples": len(tdays)}
return {"ok": True, "ymd": tdays[-1], "value": round(val, 4),
"detail": detail, "reason": ""}
# ================================================================ 区域判定 (迟滞)
def zone_next(value, prev_zone: str, params: dict) -> str:
"""区域状态机。进入阈值与退出带不同 (迟滞), 防止指数在阈值附近横跳造成反复触发。
value None UNAVAILABLE上一区域是 UNAVAILABLE 时按 NEUTRAL 的进入规则重新判
偏热区允许直接翻到偏冷区 (单日巨变), 反之亦然 由进入条件自然覆盖
"""
hot_th = float(params.get("hot_th") or 25.0)
cold_th = float(params.get("cold_th") or -20.0)
band = float(params.get("exit_band") or 15.0)
if value is None:
return Z_UNAVAILABLE
v = float(value)
prev = prev_zone if prev_zone in (Z_HOT, Z_COLD) else Z_NEUTRAL
if prev == Z_HOT:
if v < cold_th:
return Z_COLD
return Z_HOT if v >= band else Z_NEUTRAL
if prev == Z_COLD:
if v > hot_th:
return Z_HOT
return Z_COLD if v <= -band else Z_NEUTRAL
if v > hot_th:
return Z_HOT
if v < cold_th:
return Z_COLD
return Z_NEUTRAL
def depth_of(value, zone: str, params: dict) -> float:
"""超额深度 e = 越过进入阈值多少个指数点。迟滞延续期内可能为负, 一律截到 0。"""
if value is None or zone not in (Z_HOT, Z_COLD):
return 0.0
if zone == Z_HOT:
return max(0.0, float(value) - float(params.get("hot_th") or 25.0))
return max(0.0, float(params.get("cold_th") or -20.0) - float(value))
def carry_cycle(prev_zone: str, prev_cycle, zone: str, value, params: dict,
today_ymd: int) -> tuple:
"""极值周期状态推进。返回 (今日 cycle, 退出事件)。
cycle = {"zone","start","streak","e_peak","done_shift"}; 不在极值区时为 None
退出事件 = 上一日的 cycle (从极值区回到带内那天触发, 供偏热侧回落再动取峰值深度)
同日重扫的合并 (保留当日已执行量) 由调用方做, 本函数只管昨天 今天的推进
"""
exit_event = None
if zone in (Z_HOT, Z_COLD):
e_now = depth_of(value, zone, params)
if prev_cycle and prev_cycle.get("zone") == zone:
cyc = {"zone": zone, "start": prev_cycle.get("start") or today_ymd,
"streak": int(prev_cycle.get("streak") or 0) + 1,
"e_peak": max(float(prev_cycle.get("e_peak") or 0.0), e_now),
"done_shift": float(prev_cycle.get("done_shift") or 0.0)}
else:
# 换区 (含 HOT 直接翻 COLD): 旧周期算退出, 新周期从头计
if prev_cycle and prev_cycle.get("zone") in (Z_HOT, Z_COLD):
exit_event = dict(prev_cycle)
cyc = {"zone": zone, "start": today_ymd, "streak": 1,
"e_peak": e_now, "done_shift": 0.0}
return cyc, exit_event
if prev_cycle and prev_cycle.get("zone") in (Z_HOT, Z_COLD):
exit_event = dict(prev_cycle)
return None, exit_event
# ================================================================ 对数映射与决策
def log_target(e: float, params: dict) -> float:
"""周期累计目标调整幅度 target(e) = min(S0·ln(1+e/k), SHIFT_MAX)。e ≤ 0 → 0。"""
s0 = float(params.get("log_s0") or 0.049)
k = float(params.get("log_k") or 1.6)
cap = float(params.get("shift_max") or 0.20)
if e is None or e <= 0 or k <= 0 or s0 <= 0:
return 0.0
return min(s0 * math.log(1.0 + e / k), cap)
def decide(*, zone: str, cycle, exit_event, exit_acted: bool, e_now: float,
flags: dict, params: dict) -> dict:
"""升降仓决策 (纯逻辑)。一次扫描至多给出一个动作。
flags 由调用方备齐:
portfolio_pct 当前总仓位 (占规模, 0~1)
portfolio_cap 总仓上限 (加仓天花板的兜底)
buy_halt 全局暂停买入生效中
brake_active 组合刹车生效中
inflight 署名 macro 的命令仍在途
cooldown_left_reduce / cooldown_left_increase 同方向冷却剩余交易日
返回 {"action": None|REDUCE|INCREASE, "pct", "why", "blocked": [原因]}
blocked 非空表示本想动但被拦 调用方据此留痕; 平静日两者皆空
"""
out = {"action": None, "pct": 0.0, "why": "", "blocked": []}
confirm = max(1, int(params.get("confirm_days") or 1))
step_min = float(params.get("step_min") or 0.02)
trig_hot = str(params.get("trigger_hot") or TRIG_EXIT)
trig_cold = str(params.get("trigger_cold") or TRIG_ENTER)
pf = float(flags.get("portfolio_pct") or 0.0)
def _want(direction):
"""算出本次想动的步长 (未过让路检查)。返回 (step, why) 或 (0, '')。"""
if direction == ACT_REDUCE:
mode, ez = trig_hot, Z_HOT
else:
mode, ez = trig_cold, Z_COLD
if mode == TRIG_ENTER:
if not (zone == ez and cycle and int(cycle.get("streak") or 0) >= confirm):
return 0.0, ""
done = float(cycle.get("done_shift") or 0.0)
step = log_target(e_now, params) - done
if done <= 0 and 0 < step < step_min:
step = step_min # 确认当日首步保底
if step < step_min:
return 0.0, ""
return step, (f"进入{'偏热' if ez == Z_HOT else '偏冷'}区第 {cycle.get('streak')} 天, "
f"深度 {e_now:.1f}, 周期已调 {done:.2%}")
# 回落再动: 只在退出事件当天触发一次
if not (exit_event and exit_event.get("zone") == ez and not exit_acted):
return 0.0, ""
if zone in (Z_HOT, Z_COLD):
return 0.0, "" # 直接翻到另一极值区: 旧周期退出不动作
peak = float(exit_event.get("e_peak") or 0.0)
step = max(min(log_target(peak, params), float(params.get("shift_max") or 0.20)),
step_min)
return step, (f"{'偏热' if ez == Z_HOT else '偏冷'}周期结束 (峰值深度 {peak:.1f}), "
f"回落穿出退出带")
# ---- 偏热 → 降仓 ----
step, why = _want(ACT_REDUCE)
if step > 0:
blocked = []
if flags.get("inflight"):
blocked.append("上一条宏观命令仍在途, 不叠加")
if int(flags.get("cooldown_left_reduce") or 0) > 0:
blocked.append(f"降仓冷却剩 {flags['cooldown_left_reduce']} 个交易日")
floor = float(params.get("min_pct") or 0.20)
step = min(step, pf - floor)
if step < step_min:
blocked.append(f"已到宏观降仓地板 (当前仓位 {pf:.1%}, 地板 {floor:.0%})")
if blocked:
return {**out, "blocked": [f"想降仓但被拦: {b}" for b in blocked], "why": why}
return {**out, "action": ACT_REDUCE, "pct": round(step, 4), "why": why}
# ---- 偏冷 → 升仓 ----
step, why = _want(ACT_INCREASE)
if step > 0:
blocked = []
if flags.get("inflight"):
blocked.append("上一条宏观命令仍在途, 不叠加")
if int(flags.get("cooldown_left_increase") or 0) > 0:
blocked.append(f"升仓冷却剩 {flags['cooldown_left_increase']} 个交易日")
if flags.get("buy_halt"):
blocked.append("全局暂停买入生效中")
if flags.get("brake_active") and bool(params.get("respect_brake", True)):
blocked.append("组合刹车生效中 (宏观自动升仓让位)")
cap = float(flags.get("portfolio_cap") or 1.0)
max_pct = float(params.get("max_pct") or 0.0)
ceiling = min(cap, max_pct) if max_pct > 0 else cap
step = min(step, ceiling - pf)
if step < step_min:
blocked.append(f"已到升仓天花板 (当前仓位 {pf:.1%}, 天花板 {ceiling:.0%})")
if blocked:
return {**out, "blocked": [f"想升仓但被拦: {b}" for b in blocked], "why": why}
return {**out, "action": ACT_INCREASE, "pct": round(step, 4), "why": why}
return out