tradingSystem/scripts/calibrate_macro_signal.py

594 lines
26 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 §10: 在编码接入之前, 先用真实数据回答四件事:
1. 三张源表 (zs_day_data / gp_fx_daily / gp_shibor) 的位置与列名
—— 自动探查并打印, 供方案 §4.2 回填钉死;
2. hedge_index 历史分布 —— ±25 阈值合不合身, 各阈值触发频率;
3. 触发口径对比 —— 进区即动 (zone_enter, 含确认1/2日两档) vs 极值回落再动
(zone_exit), 触发后 5/10/20 交易日上证走势孰优 (样本会很小, 当参考不当真理);
4. 对数映射标定 —— target = S0·ln(1+e/k) 的 S0/k 建议值与 e→幅度 对照表。
运行 (桥机 factorevaluation, **不需要重建镜像** —— 从宿主工作树经 stdin 喂给容器 python;
`python -` 的 sys.path[0] 是工作目录 /app, config.settings 照常可导入):
cd ~/tradingSystem # git pull 之后
docker compose run --rm -T pms-web python - < scripts/calibrate_macro_signal.py \
> /tmp/macro_calib_report.md
cat /tmp/macro_calib_report.md
# 需要完整指数序列时 (CSV 到 stdout):
docker compose run --rm -T pms-web python - --dump-csv < scripts/calibrate_macro_signal.py \
> /tmp/hedge_series.csv
**严格只读**: 只发 SELECT / SHOW, 不写任何表、不建任何东西。
列名自动探查失败时会打出该表全部列名与样本行, 按提示用
--fx-table/--fx-date-col/--fx-price-col/--fx-pair-col/--fx-pair
--shibor-table/--shibor-date-col/--shibor-value-col/--shibor-term-col/--shibor-term
--zs-date-col/--zs-close-col/--zs-symbol-col/--index-code
覆盖后重跑。数据源优先级默认 proxy(153),index(199) —— 用户口径两张 gp 表在 153。
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
from datetime import date, datetime, timedelta
try:
from sqlalchemy import create_engine, text
except Exception as e: # pragma: no cover
print(f"FATAL: 需要 sqlalchemy (+pymysql), 请在 PMS 容器里跑。{e}", file=sys.stderr)
sys.exit(2)
# ================================================================ 连接
def _dsns() -> dict:
"""DSN 取值: 优先 config.settings (容器内), 退回环境变量 (裸跑)。"""
out = {}
try:
from config.settings import settings # noqa
out["proxy"] = settings.PROXY_DB_URL
out["index"] = settings.DB_MYSQL_URL
except Exception:
out["proxy"] = os.environ.get("PROXY_DB_URL", "")
out["index"] = os.environ.get("DB_MYSQL_URL", "")
return {k: v for k, v in out.items() if v}
_engines = {}
def _eng(name: str, dsn: str):
if name not in _engines:
_engines[name] = create_engine(dsn, pool_pre_ping=True,
connect_args={"connect_timeout": 5}, future=True)
return _engines[name]
def _rows(name: str, dsn: str, sql: str, params=None) -> list:
with _eng(name, dsn).connect() as c:
return [dict(r) for r in c.execute(text(sql), params or {}).mappings().fetchall()]
# ================================================================ 探查
def discover(table: str, sources: list, dsns: dict) -> dict:
"""在各源上找这张表: 返回 {source, columns[], sample[]}; 全部失败返回 {errors}"""
errors = {}
for src in sources:
dsn = dsns.get(src)
if not dsn:
errors[src] = "DSN 未配置"
continue
cols = None
try:
cols = [str(r.get("Field") or r.get("field")) for r in
_rows(src, dsn, f"SHOW COLUMNS FROM `{table}`")]
except Exception as e1:
try: # 代理不支持 SHOW 时退化: 取一行读键名
sample1 = _rows(src, dsn, f"SELECT * FROM `{table}` LIMIT 1")
cols = list(sample1[0].keys()) if sample1 else None
if cols is None:
errors[src] = f"表存在但为空? SHOW 失败: {e1}"
continue
except Exception as e2:
errors[src] = f"{type(e2).__name__}: {str(e2)[:160]}"
continue
try:
sample = _rows(src, dsn, f"SELECT * FROM `{table}` LIMIT 3")
except Exception:
sample = []
return {"source": src, "columns": cols, "sample": sample}
return {"errors": errors}
def pick(cols: list, cands: list):
low = {c.lower(): c for c in cols}
for c in cands:
if c in low:
return low[c]
for c in cands: # 次选: 前缀/包含
for lc, orig in low.items():
if lc.startswith(c) or c in lc:
return orig
return None
DATE_CANDS = ["trade_date", "timestamp", "date", "ymd", "day", "quote_date", "data_date",
"trade_day", "dt"]
def norm_ymd(v):
"""任意日期形态 → int YYYYMMDD; 解析不了返回 None。"""
if v is None:
return None
if isinstance(v, (datetime, date)):
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 fetch_zs(args, sources, dsns):
info = discover("zs_day_data", sources, dsns)
if "errors" in info:
return None, info
cols = info["columns"]
dcol = args.zs_date_col or pick(cols, DATE_CANDS)
ccol = args.zs_close_col or pick(cols, ["close", "close_price", "px_close", "price"])
scol = args.zs_symbol_col or pick(cols, ["symbol", "ts_code", "code", "index_code"])
info.update({"date_col": dcol, "close_col": ccol, "symbol_col": scol})
if not (dcol and ccol and scol):
info["errors"] = {"guess": f"列名猜不全 date={dcol} close={ccol} symbol={scol}"}
return None, info
src, dsn = info["source"], dsns[info["source"]]
rows = []
for code in (args.index_code, args.index_code.replace(".SH", ""),
"SH" + args.index_code.split(".")[0]):
try:
rows = _rows(src, dsn,
f"SELECT `{dcol}` AS d, `{ccol}` AS v FROM `zs_day_data` "
f"WHERE `{scol}` = :c ORDER BY `{dcol}` DESC LIMIT :n",
{"c": code, "n": args.days})
except Exception as e:
info["errors"] = {"query": f"{type(e).__name__}: {str(e)[:160]}"}
return None, info
if rows:
info["symbol_used"] = code
break
series = sorted([(norm_ymd(r["d"]), float(r["v"])) for r in rows
if norm_ymd(r["d"]) and r["v"] not in (None, 0)], key=lambda x: x[0])
return series, info
def fetch_fx(args, sources, dsns):
info = discover(args.fx_table, sources, dsns)
if "errors" in info:
return None, info
cols = info["columns"]
dcol = args.fx_date_col or pick(cols, DATE_CANDS)
vcol = args.fx_price_col or pick(cols, ["close", "price", "rate", "mid", "value",
"exchange_rate", "cnh", "px"])
pcol = args.fx_pair_col or pick(cols, ["currency", "ccy_pair", "ccy", "pair", "symbol",
"code", "name", "curr", "currency_pair"])
info.update({"date_col": dcol, "price_col": vcol, "pair_col": pcol})
if not (dcol and vcol):
info["errors"] = {"guess": f"列名猜不全 date={dcol} price={vcol}"}
return None, info
src, dsn = info["source"], dsns[info["source"]]
where, params = "", {"n": args.days}
if args.fx_where:
where = f"WHERE {args.fx_where}"
elif pcol:
try:
vals = [str(list(r.values())[0]) for r in
_rows(src, dsn, f"SELECT DISTINCT `{pcol}` AS p FROM `{args.fx_table}` LIMIT 60")]
except Exception:
vals = []
info["pair_values_seen"] = vals[:30]
want = args.fx_pair
if not want:
cands = [v for v in vals if "USD" in v.upper() and "CN" in v.upper()]
cnh = [v for v in cands if "CNH" in v.upper()]
want = (cnh or cands or [None])[0]
info["pair_used"] = want
if want:
where, params = f"WHERE `{pcol}` = :p", {"p": want, "n": args.days}
else:
info["note_pair"] = "没找到 USD/CN* 形态的品种值, 按整表取 (若整表就是 USDCNH 则正确)"
rows = _rows(src, dsn,
f"SELECT `{dcol}` AS d, `{vcol}` AS v FROM `{args.fx_table}` {where} "
f"ORDER BY `{dcol}` DESC LIMIT :n", params)
series = sorted([(norm_ymd(r["d"]), float(r["v"])) for r in rows
if norm_ymd(r["d"]) and r["v"] not in (None, 0)], key=lambda x: x[0])
return series, info
def fetch_shibor(args, sources, dsns):
info = discover(args.shibor_table, sources, dsns)
if "errors" in info:
return None, info
cols = info["columns"]
dcol = args.shibor_date_col or pick(cols, DATE_CANDS)
wide = args.shibor_value_col or pick(cols, ["shibor_1m", "1m", "m1", "shibor1m",
"rate_1m", "one_month"])
info.update({"date_col": dcol, "wide_1m_col": wide})
if not dcol:
info["errors"] = {"guess": "找不到日期列"}
return None, info
src, dsn = info["source"], dsns[info["source"]]
if wide: # 宽表: 每期限一列
rows = _rows(src, dsn,
f"SELECT `{dcol}` AS d, `{wide}` AS v FROM `{args.shibor_table}` "
f"ORDER BY `{dcol}` DESC LIMIT :n", {"n": args.days})
else: # 长表: 期限一列 + 值一列
tcol = args.shibor_term_col or pick(cols, ["term", "period", "tenor", "name",
"type", "item"])
vcol = pick(cols, ["rate", "value", "shibor", "price", "close"])
info.update({"term_col": tcol, "value_col": vcol})
if not (tcol and vcol):
info["errors"] = {"guess": f"长表列名猜不全 term={tcol} value={vcol}"}
return None, info
terms = ([args.shibor_term] if args.shibor_term
else ["1M", "1m", "30", "30D", "1月", "1个月", "一个月"])
rows = []
for t in terms:
rows = _rows(src, dsn,
f"SELECT `{dcol}` AS d, `{vcol}` AS v FROM `{args.shibor_table}` "
f"WHERE `{tcol}` = :t ORDER BY `{dcol}` DESC LIMIT :n",
{"t": t, "n": args.days})
if rows:
info["term_used"] = t
break
series = sorted([(norm_ymd(r["d"]), float(r["v"])) for r in rows
if norm_ymd(r["d"]) and r["v"] is not None], key=lambda x: x[0])
return series, info
# ================================================================ 对齐与计算
def asof_align(trade_days: list, series: list, shift_days: int = 0) -> tuple:
"""把 (ymd,value) 序列 as-of 对齐到交易日历。shift_days: 先把数据日期 +N 自然日。
返回 (对齐后的值列表, 补齐天数)。找不到任何前值的交易日置 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(vals: list, win: int) -> list:
out = [None] * len(vals)
for i in range(len(vals)):
w = [v for v in vals[max(0, i - win + 1): i + 1] if v is not None]
if len(w) < win:
continue
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 and vals[i] is not None:
out[i] = (vals[i] - m) / sd * 10.0
return out
def pctl(sorted_vals: list, q: float):
if not sorted_vals:
return None
k = (len(sorted_vals) - 1) * q
lo, hi = int(math.floor(k)), int(math.ceil(k))
if lo == hi:
return sorted_vals[lo]
return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (k - lo)
# ================================================================ 区段与事件
def episodes(idx: list, th: float, exit_band: float, side: int) -> list:
"""side=+1 找 HOT (v>th), side=-1 找 COLD (v<-th)。带迟滞: 退出条件 |v|<exit_band。
返回 [{start,end,exit_i,max_depth,len}], exit_i=首个满足退出条件的 bar (可能 None=样本末)。"""
out, in_ep, st, mx = [], False, 0, 0.0
for i, v in enumerate(idx):
if v is None:
continue
sv = v * side
if not in_ep and sv > th:
in_ep, st, mx = True, i, sv - th
elif in_ep:
mx = max(mx, sv - th)
if sv < exit_band:
out.append({"start": st, "exit_i": i, "max_depth": mx, "len": i - st})
in_ep = False
if in_ep:
out.append({"start": st, "exit_i": None, "max_depth": mx, "len": len(idx) - st})
return out
def fwd_ret(close: list, i: int, h: int):
if i + h < len(close) and close[i] and close[i + h]:
return close[i + h] / close[i] - 1.0
return None
def ev_stats(close: list, events: list, horizons=(5, 10, 20)) -> dict:
out = {}
for h in horizons:
rs = [fwd_ret(close, i, h) for i in events]
rs = [r for r in rs if r is not None]
if not rs:
out[h] = {"n": 0}
continue
rs_sorted = sorted(rs)
out[h] = {"n": len(rs), "mean": sum(rs) / len(rs),
"median": pctl(rs_sorted, 0.5),
"win_pos": sum(1 for r in rs if r > 0) / len(rs)}
return out
def fmt_ev(st: dict) -> str:
ps = []
for h in (5, 10, 20):
s = st.get(h) or {}
if not s.get("n"):
ps.append(f"{h}日:无样本")
else:
ps.append(f"{h}日: n={s['n']} 均值{s['mean']*100:+.2f}% "
f"中位{s['median']*100:+.2f}% 上涨占比{s['win_pos']*100:.0f}%")
return " · ".join(ps)
# ================================================================ 对数映射标定
def calib_log(depths: list, mid_target=0.05, p95_target=0.12) -> dict:
"""解 S0·ln(1+m/k)=mid_target 且 S0·ln(1+p/k)=p95_target。
比值方程对 k 二分; 无解 (p/m 太小) 则锚中位数, k 固定 10。"""
ds = sorted(depths)
if len(ds) < 3:
return {"ok": False, "why": f"极值区段太少 ({len(ds)} 段), 用默认 S0=0.12 k=10",
"S0": 0.12, "k": 10.0, "m": pctl(ds, 0.5) if ds else None}
m, p = max(pctl(ds, 0.5), 0.5), max(pctl(ds, 0.95), 1.0)
R = p95_target / mid_target
f = lambda k: math.log(1 + p / k) / math.log(1 + m / k)
lo, hi = 1e-3, 1e6
if f(hi) < R: # k→∞ 比值→p/m 仍不够 → 无解
S0 = mid_target / math.log(1 + m / 10.0)
return {"ok": False, "why": f"深度分布太窄 (中位{m:.1f} / 95分位{p:.1f}), "
f"锚中位数取 S0, k 固定 10", "S0": S0, "k": 10.0,
"m": m, "p": p}
for _ in range(200):
mid = math.sqrt(lo * hi)
# f(k) 随 k 单调递增 (k→0 时→1, k→∞ 时→p/m): 比值还不够大就要更大的 k
if f(mid) < R:
lo = mid
else:
hi = mid
k = math.sqrt(lo * hi)
S0 = mid_target / math.log(1 + m / k)
return {"ok": True, "S0": S0, "k": k, "m": m, "p": p}
# ================================================================ 主流程
def main():
ap = argparse.ArgumentParser(description="股汇对冲指数标定 (只读)")
ap.add_argument("--days", type=int, default=1600, help="回看条数 (交易日, 默认约6年)")
ap.add_argument("--index-code", default="000001.SH")
ap.add_argument("--beta", type=float, default=0.02)
ap.add_argument("--ret-win", type=int, default=20)
ap.add_argument("--z-win", type=int, default=40)
ap.add_argument("--th", type=float, default=25.0, help="主阈值")
ap.add_argument("--exit-band", type=float, default=15.0)
ap.add_argument("--thresholds", default="20,25,30", help="敏感性对比的阈值列表")
ap.add_argument("--source-priority", default="proxy,index",
help="按序尝试的数据源 (用户口径: 两张 gp 表在 153=proxy)")
ap.add_argument("--fx-table", default="gp_fx_daily")
ap.add_argument("--fx-date-col"), ap.add_argument("--fx-price-col")
ap.add_argument("--fx-pair-col"), ap.add_argument("--fx-pair")
ap.add_argument("--fx-where", help="整段 WHERE 逃生口, 如 \"ccy='USDCNH'\"")
ap.add_argument("--shibor-table", default="gp_shibor")
ap.add_argument("--shibor-date-col"), ap.add_argument("--shibor-value-col")
ap.add_argument("--shibor-term-col"), ap.add_argument("--shibor-term")
ap.add_argument("--zs-date-col"), ap.add_argument("--zs-close-col")
ap.add_argument("--zs-symbol-col")
ap.add_argument("--dump-csv", action="store_true", help="只输出完整序列 CSV")
args = ap.parse_args()
dsns = _dsns()
if not dsns:
print("FATAL: PROXY_DB_URL / DB_MYSQL_URL 都拿不到 (容器内跑, 或导出环境变量)",
file=sys.stderr)
sys.exit(2)
sources = [s.strip() for s in args.source_priority.split(",") if s.strip() in dsns]
# ---- 取数 ----
zs, zi = fetch_zs(args, sources, dsns)
fx, fi = fetch_fx(args, sources, dsns)
sh, si = fetch_shibor(args, sources, dsns)
def head(name, info, series):
lines = [f"### {name}"]
if info.get("source"):
lines.append(f"- 源: **{info['source']}** ({dsns[info['source']].split('@')[-1]})")
if info.get("columns"):
lines.append(f"- 全部列: `{', '.join(info['columns'])}`")
for k in ("date_col", "close_col", "symbol_col", "symbol_used", "price_col",
"pair_col", "pair_used", "wide_1m_col", "term_col", "value_col",
"term_used", "note_pair"):
if info.get(k):
lines.append(f"- {k}: `{info[k]}`")
if info.get("pair_values_seen"):
lines.append(f"- 品种值样本: {info['pair_values_seen']}")
if series:
lines.append(f"- 数据: {len(series)} 条, {series[0][0]}{series[-1][0]}, "
f"末值 {series[-1][1]}")
if info.get("errors"):
lines.append(f"- **失败**: {info['errors']} —— 按文件头提示带覆盖参数重跑")
if info.get("sample"):
lines.append(f"- 样本行: `{json.dumps(info['sample'][:1], ensure_ascii=False, default=str)[:400]}`")
return "\n".join(lines)
if not args.dump_csv:
print("# 股汇对冲指数 · 标定报告")
print(f"\n> 生成: {datetime.now().isoformat(timespec='seconds')} · "
f"参数: ret_win={args.ret_win} z_win={args.z_win} beta={args.beta} "
f"主阈值±{args.th} 退出带±{args.exit_band} 回看={args.days}\n")
print("## 一、表探查 (回填方案 §4.2 用)\n")
print(head("zs_day_data (上证)", zi, zs), "\n")
print(head(f"{args.fx_table} (USD/CNH)", fi, fx), "\n")
print(head(f"{args.shibor_table} (SHIBOR 1M)", si, sh), "\n")
if not (zs and fx and sh):
if args.dump_csv:
print("FATAL: 取数不全, 先跑一遍报告模式看探查结果", file=sys.stderr)
else:
print("\n**取数不全, 后续标定跳过。** 按上面失败提示带覆盖参数重跑。")
sys.exit(1)
# ---- 对齐 (交易日历 = zs 日期) ----
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, args.ret_win)
fr = log_rets(fx_al, args.ret_win)
sd = diffs(sh_al, args.ret_win)
spread = [None if (sr[i] is None or fr[i] is None) else sr[i] + fr[i]
for i in range(len(tdays))]
spread_adj = [None if (spread[i] is None or sd[i] is None)
else spread[i] - args.beta * sd[i] for i in range(len(tdays))]
idx = roll_z(spread_adj, args.z_win)
if args.dump_csv:
print("ymd,sh_close,fx,shibor_1m,stock_ret20,fx_ret20,spread,spread_adj,hedge_index")
for i, d in enumerate(tdays):
row = [d, close[i], fx_al[i], sh_al[i], sr[i], fr[i], spread[i],
spread_adj[i], idx[i]]
print(",".join("" if v is None else (f"{v:.6f}" if isinstance(v, float) else str(v))
for v in row))
return
valid = [v for v in idx if v is not None]
print("## 二、数据体检\n")
print(f"- 交易日历 (取自上证): {len(tdays)} 天, 最新 {tdays[-1]} (距今天 "
f"{(date.today() - datetime.strptime(str(tdays[-1]), '%Y%m%d').date()).days} 自然日)")
print(f"- 汇率末日 {fx[-1][0]} · as-of 补齐 {fx_fill} 天; "
f"SHIBOR 末日 {sh[-1][0]} · 补齐 {sh_fill}")
print(f"- hedge_index 有效样本 {len(valid)} 天 (预热损耗 {len(tdays) - len(valid)} 天)")
if valid:
print(f"- 当前值: **{valid[-1]:+.1f}**")
if len(valid) < 120:
print("\n**有效样本不足 120 天, 分布与口径对比意义有限, 到此为止。**")
sys.exit(1)
sv = sorted(valid)
print("\n分位数: " + " · ".join(
f"P{int(q*100)}={pctl(sv, q):+.1f}" for q in (0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99)))
# ---- 阈值敏感性 ----
print("\n## 三、阈值敏感性 (±TH 触发频率与事后走势)\n")
print("| TH | 超阈天数占比 | HOT段/年 | COLD段/年 | HOT进区后10日均值 | COLD进区后10日均值 |")
print("|---|---|---|---|---|---|")
years = max(len(valid) / 244.0, 0.1)
for th in [float(x) for x in args.thresholds.split(",")]:
eb = th * args.exit_band / args.th # 退出带按比例缩放
hot = episodes(idx, th, eb, +1)
cold = episodes(idx, th, eb, -1)
beyond = sum(1 for v in valid if abs(v) > th) / len(valid)
h10 = ev_stats(close, [e["start"] for e in hot]).get(10, {})
c10 = ev_stats(close, [e["start"] for e in cold]).get(10, {})
f = lambda s: (f"{s['mean']*100:+.2f}% (n={s['n']})" if s.get("n") else "无样本")
print(f"| ±{th:.0f} | {beyond*100:.1f}% | {len(hot)/years:.1f} | "
f"{len(cold)/years:.1f} | {f(h10)} | {f(c10)} |")
# ---- 触发口径对比 (主阈值) ----
th, eb = args.th, args.exit_band
hot, cold = episodes(idx, th, eb, +1), episodes(idx, th, eb, -1)
print(f"\n## 四、触发口径对比 (主阈值±{th:.0f}, 退出带±{eb:.0f})\n")
print(f"- HOT 区段 {len(hot)} 段 (平均持续 "
f"{sum(e['len'] for e in hot)/max(len(hot),1):.1f} 天, "
f"最大深度 {max((e['max_depth'] for e in hot), default=0):.1f}); "
f"COLD 区段 {len(cold)} 段 (平均 "
f"{sum(e['len'] for e in cold)/max(len(cold),1):.1f} 天, "
f"最大深度 {max((e['max_depth'] for e in cold), default=0):.1f})\n")
def second_day(eps): # 确认=2: 区段第 2 天 (不足 2 天的区段无事件)
return [e["start"] + 1 for e in eps if e["len"] >= 2]
rows = [
("HOT · 进区即动(确认1)", [e["start"] for e in hot], "降仓视角: 越负越好"),
("HOT · 进区确认2日", second_day(hot), "降仓视角: 越负越好"),
("HOT · 极值回落再动", [e["exit_i"] for e in hot if e["exit_i"] is not None],
"降仓视角: 越负越好(但可能已回落)"),
("COLD · 进区即动(确认1)", [e["start"] for e in cold], "升仓视角: 越正越好"),
("COLD · 进区确认2日", second_day(cold), "升仓视角: 越正越好"),
("COLD · 极值回落再动", [e["exit_i"] for e in cold if e["exit_i"] is not None],
"升仓视角: 越正越好"),
]
for name, evs, note in rows:
print(f"**{name}** ({note})\n> {fmt_ev(ev_stats(close, evs))}\n")
print("> 提醒: 样本很小, 这是参考不是显著性检验; 两口径都已实现、页面可切, 这里只定默认档。\n")
# ---- 对数映射标定 ----
print(f"## 五、对数映射标定 target = S0 · ln(1 + e/k), e = |idx| {th:.0f}\n")
depths = [e["max_depth"] for e in hot + cold]
cal = calib_log(depths)
if depths:
dsrt = sorted(depths)
print(f"- 区段最大深度 e 分布: 中位 {pctl(dsrt, 0.5):.1f} · "
f"P95 {pctl(dsrt, 0.95):.1f} · 最大 {dsrt[-1]:.1f} (共 {len(depths)} 段)")
tag = "标定成功" if cal.get("ok") else f"退化取值 ({cal.get('why')})"
print(f"- **建议初值: PMS_MACRO_LOG_S0 = {cal['S0']:.3f} · "
f"PMS_MACRO_LOG_K = {cal['k']:.1f}** —— {tag}")
print(f"- 目标形状: e 中位数 → 约 5 个点仓位, e 95分位 → 约 12 个点, "
f"SHIFT_MAX 封顶 20 个点\n")
print("| e (超额深度) | 2 | 5 | 8 | 10 | 15 | 20 | 30 | 40 |")
print("|---|" + "---|" * 8)
tgt = lambda e: min(cal["S0"] * math.log(1 + e / cal["k"]), 0.20)
print("| 累计调整幅度 | " + " | ".join(f"{tgt(e)*100:.1f}%" for e in
(2, 5, 8, 10, 15, 20, 30, 40)) + " |")
print("\n## 六、把这些数拿回去干什么\n")
print("1. 表探查一节的 源/列名 → 回填 MACRO_TIMING_PLAN.md §4.2, 才能写 macro_repo;")
print("2. 第三节选 TH 与退出带 → PMS_MACRO_HOT_TH / COLD_TH / EXIT_BAND;")
print("3. 第四节选触发口径默认档 → PMS_MACRO_TRIGGER_MODE / CONFIRM_DAYS;")
print("4. 第五节的 S0/k → PMS_MACRO_LOG_S0 / LOG_K;")
print("5. 报告全文存档进仓库 (建议 docs/ 或贴回对话), 作为参数初值的依据留痕。")
if __name__ == "__main__":
main()