tradingSystem/app/core/macro_rules.py

346 lines
16 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 -*-
"""
宏观择时 · 纯逻辑 (计算 / 区域判定 / 决策; 无外部依赖, 可单测)
================================================================
设计与参数依据: 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