# -*- 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