594 lines
26 KiB
Python
594 lines
26 KiB
Python
|
|
# -*- 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()
|