346 lines
16 KiB
Python
346 lines
16 KiB
Python
|
|
# -*- 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
|