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