From 06928d1d5f9982e273e96a7ab37157aec3e6d106 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 28 Aug 2026 15:54:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E4=BB=93=E5=89=8D=E9=82=A3=E6=AE=B5?= =?UTF-8?q?=EF=BC=9A=E9=87=8F=E8=A6=81=E5=B0=8F=E3=80=81=E5=8A=BF=E8=A6=81?= =?UTF-8?q?=E6=9C=89=E4=BD=86=E4=B8=8D=E8=83=BD=E8=BF=87=E5=A4=B4=EF=BC=8C?= =?UTF-8?q?=E6=8D=A2=E6=8E=89=20vr60=20=E9=82=A3=E4=B8=AA=E5=BC=80?= =?UTF-8?q?=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户提出看开仓之前的量与趋势方向。这是第三个位置——step50 管信号根、 step52 管持仓中,这里管入场前。step48 采集端补 vpre10/vpre30/mom10/mom60。 先纠正一个错的心智模型(我和用户都以为的):B4/S4 不是回抽后进场。mom10 中位 +3.47 ATR,为负的只占 6%。信号触发时价格在前 10 根里已经顺着你的方向 走了三个多 ATR,2 ATR 止损是架在一段已经走完的行情后面。这也解释了 step50: 信号根是突破根,放量 = 追在最后一棒上。 ① 入场前的量单调,越小越好(样本外毛R,Q1→Q4):1.474 / 1.266 / 1.156 / 0.573。控 vr60 后仍成立(低量层 −0.284、高量层 −0.541),与 vr60 相关只有 +0.301,不是同一件事换个说法。 ② 入场前的动量是驼峰形,不是单调(mom60 样本外毛R,Q1→Q4):1.252 / 1.473 / 1.141 / 0.603。势要有——完全没动过的 Q1 也不如 Q2;但不能过头—— Q4 在发现期余量只剩 1.44bp,等于不能做。 驼峰形意味着中位数二分法会把它测没:控制表里 mom10 的毛R差是 +0.044, 看着无效,那是二分把 Q1+Q2 和 Q3+Q4 各自平均了。对非单调因子不要用中位数 分层做检验。 ③ 砍 mom60≥7 全面优于 §3.39 定的砍 vr60≥4(发现期): 等权 保留 100% 盈亏平衡 13.5bp R夏普 0.325 回撤 16.8 总R 596.6 砍 vr60≥4 保留 69% 盈亏平衡 15.6bp R夏普 0.411 回撤 10.8 总R 514.9 砍 mom60≥7 保留 78% 盈亏平衡 17.5bp R夏普 0.476 回撤 7.7 总R 657.7 每一项都赢,还多留 9 个点的笔数。更要紧的是总R 比不砍还高——被砍掉那 22% 期望为负,砍掉不是花钱买稳健,是纯赚。样本外同向。所以这个开关不需要等 影子测量:它在 0~20bp 每一档都不输等权。 附滑点决策表:5~12bp 区间砍 mom60≥7 通吃,只有 ≥15bp 才该上「三个都砍」, 而那时策略本身已在生死线上。 阈值 7 和 1.5 是贴着 Q4 边界取的整数,不是搜出来的,但也不是完全无关于数据 (看过分位表才取的整),上线前应确认阈值附近没有断崖敏感。 Co-authored-by: Cursor --- research/HANDOFF.md | 106 +++++++++++++++++-- research/step48_signal_timing.py | 31 +++++- research/step53_pre_entry.py | 169 +++++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 research/step53_pre_entry.py diff --git a/research/HANDOFF.md b/research/HANDOFF.md index 5fc86e2..041563b 100644 --- a/research/HANDOFF.md +++ b/research/HANDOFF.md @@ -765,6 +765,11 @@ PF 从 1.59 提到 1.85(§4 step30)。**两个过滤器的相对价值随级 但兑现方式不是仓位。(可能的出路是对这批放宽 ATR 门控,即多做几笔而非 每笔做大,那条仍未测。) +> ⚠️ **本节推荐的开关(`vr60 ≥ 4 不做`)已被 §3.391 取代。** +> `mom60 ≥ 7 不做` 在盈亏平衡滑点、R夏普、回撤、总R、保留笔数五项上全面更优。 +> 本节关于**「开关优于权重」「前序不该进仓位」「优势形态是削尾」**三条方法论 +> 结论仍然成立,只是最佳的那个开关换了个因子。 + **② 量因子「不做」优于「少做」。** 加权版 R夏普 0.442、硬砍版 0.496; 回撤 12.7 vs 11.2;峰值并发 7.98 vs **6.00**。硬砍付出笔数 −22% (3941 → 3061)、总R −10%,换来回撤 −35% 和并发 −14%。 @@ -781,6 +786,88 @@ PF 从 1.59 提到 1.85(§4 step30)。**两个过滤器的相对价值随级 发现期才代表当前波动环境。13.5bp 与 `shadow_budget.BUDGET_PORTFOLIO_2026` 的 15.19bp 同量级,互为印证。**影子测量要对标的是 13.5 / 15.6,不是 18.6。** +### 3.391 开仓前那段:量要小、势要有但不能过头(step53)⭐ + +用户提出看开仓**之前**的量与趋势方向。这是第三个位置——step50 管信号根、 +step52 管持仓中,这里管入场前。 + +**先纠正一个错的心智模型(我和用户都以为的)**:B4/S4 不是回抽后进场。 +`mom10` 中位 **+3.47 ATR**,为负的只占 **6%**。也就是说信号触发时,价格在前 +10 根里已经顺着你的方向走了三个多 ATR,**你的 2 ATR 止损是架在一段已经走完 +的行情后面**。所谓"回抽后转强"里的回抽,幅度远小于它之前那一冲。 + +这一条也解释了 step50:信号根是突破根,放量 = 追在最后一棒上。 + +**① 入场前的量:单调,越小越好,且独立于信号根的量** + +| vpre10(前 10 根量均值,已 shift(1) 不含信号根) | 样本外毛R | 样本外余量 | 发现期毛R | 发现期余量 | +|---|---|---|---|---| +| Q1 最低(中位 0.61) | **1.474** | **27.25bp** | 1.074 | 17.85bp | +| Q2 | 1.266 | 23.45bp | 1.184 | 19.76bp | +| Q3 | 1.156 | 21.37bp | 1.033 | 18.61bp | +| Q4 最高(中位 2.21) | 0.573 | 8.51bp | 0.510 | 6.04bp | + +vpre30 同形状。**控 `vr60` 后仍成立**:信号根低量层里毛R差 −0.284、 +高量层 −0.541,与 `vr60` 相关只有 **+0.301**,不是同一件事换个说法。 + +**② 入场前的动量:驼峰形,不是单调** + +| mom60(前 60 根顺向动量,ATR 单位) | 样本外毛R | 样本外余量 | 发现期毛R | 发现期余量 | +|---|---|---|---|---| +| Q1 最低(中位 0.33) | 1.252 | 22.60bp | 1.180 | 19.47bp | +| **Q2(中位 3.14)** | **1.473** | **28.68bp** | **1.355** | **26.26bp** | +| Q3 | 1.141 | 20.49bp | 0.981 | 16.23bp | +| Q4 最高(中位 8.33) | 0.603 | 8.65bp | **0.284** | **1.44bp** | + +mom10 同形状(Q2 最好、Q4 最差),样本内外一致。**势要有——完全没动过的 Q1 +也不如 Q2;但不能过头——Q4 在发现期余量只剩 1.44bp,等于不能做。** + +⚠️ 驼峰形意味着**中位数二分法会把它测没**:控制表里 mom10 在"信号根低量"层 +的毛R差是 +0.044(看着无效),那是因为二分把 Q1+Q2 和 Q3+Q4 各自平均了。 +**对非单调因子不要用中位数分层做检验。** + +**③ 逆向进场(真回抽)很差,但只占 6%** + +| | 笔数 | 占比 | 毛R | 余量bp | +|---|---|---|---|---| +| 顺向 × 缩量 | 1851 | 47% | **1.350** | **24.63** | +| 顺向 × 放量 | 1872 | 48% | 0.855 | 14.35 | +| 逆向 × 缩量 | 119 | 3% | 0.615 | 7.81 | +| 逆向 × 放量 | 99 | 3% | 0.362 | 3.61 | + +**④ `mom60≥7 不做` 全面优于 §3.39 定的 `vr60≥4 不做` —— 换开关** + +发现期(当前波动环境),阈值取整数不搜参: + +| 方案 | 保留 | 盈亏平衡滑点 | R夏普 | 回撤R | 总R | +|---|---|---|---|---|---| +| 等权(现状) | 100% | 13.5bp | 0.325 | 16.8 | 596.6 | +| 砍 vr60≥4(§3.39 的建议) | 69% | 15.6bp | 0.411 | 10.8 | 514.9 | +| **✅ 砍 mom60≥7** | **78%** | **17.5bp** | **0.476** | **7.7** | **657.7** | +| 砍 vr60≥4 + mom60≥7 | 58% | 18.3bp | 0.515 | 8.0 | 527.8 | +| 三个都砍(+vpre10≥1.5) | 42% | 20.0bp | 0.593 | 6.5 | 430.4 | + +**`砍 mom60≥7` 在每一项上都赢过 `砍 vr60≥4`,还多留 9 个点的笔数。** +更要紧的是它**总R 比不砍还高**(657.7 vs 596.6):被砍掉那 22% 的单子期望为负, +砍掉不是花钱买稳健,是纯赚。样本外同向(21.4bp / 0.588 / 1923.6, +对照砍 vr60 的 19.8 / 0.529 / 1820.8)。 + +**选哪个方案取决于真实滑点,这是决策表**(发现期总R): + +| 真实滑点 | 0bp | 5bp | 8bp | 10bp | 12bp | 15bp | 18bp | 20bp | +|---|---|---|---|---|---|---|---|---| +| 等权 | **946** | 597 | 387 | 247 | 107 | −102 | −312 | −452 | +| 砍 mom60≥7 | 921 | **658** | **500** | **394** | **289** | 131 | −28 | −133 | +| 三个都砍 | 574 | 430 | 344 | 287 | 229 | **143** | **57** | −0 | + +**5~12bp 区间 `砍 mom60≥7` 通吃**,那正是最可能的落点。只有真实滑点 ≥15bp +才该上"三个都砍",而那时策略本身已经在生死线上。**影子测量出数之前不用选, +但要知道选项长这样。** + +> 阈值 `mom60 ≥ 7`、`vpre10 ≥ 1.5` 是贴着 Q4 边界取的整数,不是搜出来的, +> 但也**不是完全无关于数据**(我看过分位表才取的整)。真上线前应在更早的 +> 时段上确认阈值附近没有断崖式敏感。 + ### 3.4 alpha 的来源(step32 消融) 逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位, @@ -1816,6 +1903,7 @@ API 限流风险隔离三个好处。 | 50 | **信号根成交量**(用户假设「有资金的趋势才是好趋势」)→ **方向相反**,高量毛R 0.794 vs 低量 1.421,ATR 4/4 + 时段 7/7 同向;根因是在突破根上给别人接盘 | | 51 | **因子化仓位**(把 49/50 做成权重)→ 量因子该做成**开关**不是权重(硬砍 vs 加权:R夏普 0.496/0.442、并发 6.00/7.98);**前序因子不该进仓位**,边际收益买不起集中度。优势形态是削尾不是抬均值 | | 52 | **持仓中放量出场**(用户提议)→ 反的,最优点是永不触发;放量根标记的是止盈率翻倍的好单。自带模拟器与 `walk_exits` 逐笔对拍,10/10 币通过 | +| 53 | **开仓前的量与趋势方向**(用户提议)⭐ → 前段量单调(越小越好,独立于信号根量);前段动量**驼峰形**(Q2 最好、Q4 最差)。`砍 mom60≥7` 全面优于 §3.39 的 `砍 vr60≥4`,且总R 比不砍还高。附滑点决策表 | 输出都在 `research/out/`。**2026-08-27 清理过一轮**:step1~20 的输出(早期方法论 已被推翻,含未来函数偏差)与旧口径备份一并删除,只留支撑当前结论的证据。 @@ -1907,11 +1995,17 @@ API 限流风险隔离三个好处。 - [x] **因子化仓位已评估完(step51,§3.39)**,结论与分层表给的印象相反: 量因子做成**开关**(vr60 ≥ 4 不做),前序因子**不进仓位**。 剩下两件事: -- [ ] **决定要不要上「vr60 ≥ 4 不做」这个开关。** 收益是发现期盈亏平衡滑点 - 13.5 → 15.6bp、回撤 −35%、峰值并发 7 → 6;代价是笔数 −22% - (10 币已只有约 2.5 笔/天,砍完约 2.0)。**建议等影子测量出真实滑点 - 再定**——若实测远低于 13.5bp,等权就够用,没必要付这 22%; - 若实测贴着 13.5bp,这 2.1bp 的余量就是生死线。**这个开关只在 - live 信号路径加一行,随时可上,不必现在决定** +- [ ] **上「mom60 ≥ 7 不做」这个开关**(§3.391 已取代原来的 `vr60 ≥ 4`)。 + 与其他开关不同,**这个不需要等滑点数据**:它在 0bp 到 20bp 的每一档上 + 都不输给等权,5~12bp 区间还大幅领先,且总R 比不砍更高(657.7 vs 596.6) + ——被砍的那 22% 期望为负。**唯一的前置动作是确认阈值附近没有断崖敏感** + (阈值 7 是贴着 Q4 边界取的整数,看过分位表才取的) +- [ ] 若真实滑点实测 ≥15bp,再考虑叠加 `vpre10 ≥ 1.5` 与 `vr60 ≥ 4` + ("三个都砍",保留 42%)。滑点 <12bp 时这个叠加是亏的,别提前上 - [ ] `有前序` 的余量优势改用**放宽 ATR 门控**兑现(多做几笔而非每笔做大)。 §3.31 已记,未回测 +- [ ] **持仓中放量 → 延长 runner 目标 / 加仓**(step52 的建设性反面)。 + 已知:触发后止盈率 45.7% vs 全体 30.1%,控存活期后 5/5 档同向。 + 但基线已隐含吃到这部分(拿着不动就是),要多拿必须主动改目标位, + **那是新参数,要重新回测**。R差 随触发时点递减(0.785 → 0.281), + 窗口应偏前段 diff --git a/research/step48_signal_timing.py b/research/step48_signal_timing.py index 2558394..3540492 100644 --- a/research/step48_signal_timing.py +++ b/research/step48_signal_timing.py @@ -75,6 +75,26 @@ def collect(sym: str, rows: int) -> pd.DataFrame | None: idx = sig["entry_idx"].to_numpy().astype(int) atr = cdf["atr"].to_numpy(float)[idx] close = cdf["close"].to_numpy(float)[idx] + + vol = cdf["volume"] + vr = vol / vol.rolling(60, min_periods=10).mean() + vr60 = vr.to_numpy(float) + # shift(1) 把信号根自己排除在外 + vpre10 = vr.rolling(10, min_periods=5).mean().shift(1).to_numpy(float) + vpre30 = vr.rolling(30, min_periods=15).mean().shift(1).to_numpy(float) + c_all = cdf["close"].to_numpy(float) + a_all = cdf["atr"].to_numpy(float) + d_all = sig["direction"].to_numpy().astype(int) + + def mom(k: int) -> np.ndarray: + """顺方向动量(ATR 单位)。用全序列算好再取下标,避免逐笔切片。""" + prev = np.concatenate([np.full(k, np.nan), c_all[:-k]]) + with np.errstate(invalid="ignore", divide="ignore"): + m = (c_all - prev) / a_all + out = np.full(len(idx), np.nan) + out[:] = m[idx] * d_all + return out + return pd.DataFrame({ "sym": sym, "date": cdf["date"].to_numpy()[idx], @@ -91,8 +111,15 @@ def collect(sym: str, rows: int) -> pd.DataFrame | None: # 信号根的相对成交量。当根已收盘,开仓时可知,是合规的可交易信息。 # vr10 是引擎自带口径(前 10 根均量),vr60 换个基准做稳健性对照。 "vr10": cdf["volume_ratio"].to_numpy(float)[idx], - "vr60": (cdf["volume"] / cdf["volume"].rolling(60, min_periods=10).mean() - ).to_numpy(float)[idx], + "vr60": vr60[idx], + # 开仓**之前**那段的量与走势。vpre 已 shift(1),不含信号根本身—— + # 信号根的量单独由 vr60 承担,两者混在一起就分不清是哪一段在起作用。 + "vpre10": vpre10[idx], + "vpre30": vpre30[idx], + # 顺方向动量,ATR 为单位。B4/S4 是回抽后转强,所以 mom10 多为负 + # (入场前那几根逆着你走);负得多 = 回抽深。 + "mom10": mom(10), + "mom60": mom(60), }) except Exception as e: print(f" {sym} 失败: {e!r}", flush=True) diff --git a/research/step53_pre_entry.py b/research/step53_pre_entry.py new file mode 100644 index 0000000..9f53975 --- /dev/null +++ b/research/step53_pre_entry.py @@ -0,0 +1,169 @@ +"""Step 53:开仓**之前**那段的量与走势方向。 + +用户提出的第三个位置。前两个已有定论: + step50 信号根放量 → 差(那根你是买方,付的是资金已推到的价) + step52 持仓中放量 → 好(那是资金来接你的货) +本步问:入场之前那几根呢?资金是不是已经在里面了。 + +这跟 B4/S4 的结构直接相关。它是回抽后转强,所以入场前那几根**通常逆着你走** +(mom10 多为负,负得多 = 回抽深)。那么「回抽时缩量」与「回抽时放量」是有 +明确含义的区分——前者是没人卖,后者是真有人在卖。 + +四个因子,全部在信号根收盘时可知: + vpre10 / vpre30 前 10 / 30 根的相对量均值(已 shift(1),不含信号根) + mom10 / mom60 顺方向动量,ATR 为单位 + +⚠️ 必须控 `vr60`(信号根自身的量)。step50 已证明它是强负因子,不控的话 +前段量会通过相关性借它的力,看着有效其实是同一件事。 +""" +from __future__ import annotations + +import argparse +import os +import sys +import warnings +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + +import numpy as np +import pandas as pd + +warnings.filterwarnings("ignore") +for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"): + os.environ.setdefault(v, "1") + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(HERE.parent)) +pd.set_option("display.width", 340) + +SL = 2.0 +GATE_BP = 8.0 +OUT = HERE / "out" / "step53_pre_entry.feather" +IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai") +FACTORS = [("vpre10", "前10根量"), ("vpre30", "前30根量"), + ("mom10", "前10根顺向动量"), ("mom60", "前60根顺向动量")] + + +def collect(sym: str, rows: int): + import warnings as _w + _w.filterwarnings("ignore") + sys.path.insert(0, str(HERE)) + sys.path.insert(0, str(HERE.parent)) + from step48_signal_timing import collect as _c + return _c(sym, rows) + + +def stat(g: pd.DataFrame, lab: str, denom: int) -> dict: + if len(g) < 40: + return {"分组": lab, "笔数": len(g), "备注": "样本不足"} + w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum() + return {"分组": lab, "笔数": len(g), "占比": f"{len(g)/denom*100:.0f}%", + "胜率": f"{(g.net > 0).mean()*100:.1f}%", + "毛R": round(g.gR.mean(), 3), "净均R": round(g.R.mean(), 3), + "PF": round(w / o, 2) if o > 0 else np.inf, + "余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2)} + + +def quartiles(d: pd.DataFrame, col: str, name: str, label: str) -> None: + x = d[d[col].notna() & np.isfinite(d[col])] + if len(x) < 300: + print(f" {name}: 样本不足") + return + x = x.copy() + x["bin"] = pd.qcut(x[col], 4, labels=["Q1最低", "Q2", "Q3", "Q4最高"]) + rows = [stat(g, str(b), len(x)) for b, g in x.groupby("bin", observed=True)] + t = pd.DataFrame(rows) + med = x.groupby("bin", observed=True)[col].median().round(2).to_dict() + t.insert(1, "中位", [med.get(b) for b in t["分组"]]) + print(f"\n--- {label} / {name}({col})---") + print(t.to_string(index=False)) + + +def control_vr60(d: pd.DataFrame, col: str, name: str) -> None: + """在信号根量的高/低两半内部各切一次,看因子是否还独立成立。""" + x = d[d[col].notna() & np.isfinite(d[col]) & d.vr60.notna()].copy() + x["vr60半"] = np.where(x.vr60 >= x.vr60.median(), "信号根高量", "信号根低量") + rows = [] + for half, g in x.groupby("vr60半"): + g = g.copy() + g["h"] = pd.qcut(g[col], 2, labels=["低", "高"]) + lo, hi = g[g.h == "低"], g[g.h == "高"] + if min(len(lo), len(hi)) < 40: + continue + rows.append({"因子": name, "控制层": half, "笔数": len(g), + "低组毛R": round(lo.gR.mean(), 3), "高组毛R": round(hi.gR.mean(), 3), + "毛R差": round(hi.gR.mean() - lo.gR.mean(), 3), + "低组余量": round(lo.net.mean() / lo.tn.mean() * 1e4, 2), + "高组余量": round(hi.net.mean() / hi.tn.mean() * 1e4, 2)}) + if rows: + print(pd.DataFrame(rows).to_string(index=False)) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--symbols", default="BTC,BNB,ETH,SOL,LINK,LTC,AVAX,XRP,DOGE,ADA") + ap.add_argument("--rows", type=int, default=800_000) + ap.add_argument("--workers", type=int, default=3) + ap.add_argument("--reuse", action="store_true") + args = ap.parse_args() + + if args.reuse and OUT.exists(): + d = pd.read_feather(OUT) + else: + syms = [s.strip() for s in args.symbols.split(",")] + print(f"[开仓前的量与方向] {len(syms)} 币 × {args.rows} 根 1m\n", flush=True) + parts = [] + with ProcessPoolExecutor(max_workers=args.workers) as ex: + fut = {ex.submit(collect, s, args.rows): s for s in syms} + for i, f in enumerate(as_completed(fut), 1): + r = f.result() + print(f" [{i}/{len(syms)}] {fut[f]} {0 if r is None else len(r)}", flush=True) + if r is not None: + parts.append(r) + if not parts: + print("无结果") + return + d = pd.concat(parts, ignore_index=True) + d.to_feather(OUT) + + from step50_volume import prep + + d["date"] = pd.to_datetime(d["date"]) + d = prep(d[(d.htf == 1.0) & d.lad & (d.atr_bp >= GATE_BP)].copy()) + oos, ins = d[d.date < IS_START], d[d.date >= IS_START] + print(f"\n实盘口径 {len(d)} 笔 | 样本外 {len(oos)} 发现期 {len(ins)}") + print(f"回抽确认:mom10 中位 {d.mom10.median():+.2f} ATR," + f"为负的占 {(d.mom10 < 0).mean()*100:.0f}%") + + print("\n" + "=" * 118) + print("########## 一、四个因子各自看(样本外 / 发现期)##########") + for col, name in FACTORS: + for lab, part in (("样本外", oos), ("发现期", ins)): + quartiles(part, col, name, lab) + + print("\n" + "=" * 118) + print("########## 二、控信号根自身的量(vr60)后是否还成立 ##########") + for col, name in FACTORS: + control_vr60(d, col, name) + + print("\n" + "=" * 118) + print("########## 三、缩量回抽 vs 放量回抽 ##########") + x = d[d.vpre10.notna() & np.isfinite(d.vpre10) & d.mom10.notna()].copy() + x["回抽"] = np.where(x.mom10 < 0, "回抽(逆向)", "顺向进场") + x["前段量"] = np.where(x.vpre10 >= x.vpre10.median(), "放量", "缩量") + rows = [] + for a in ("回抽(逆向)", "顺向进场"): + for b in ("缩量", "放量"): + g = x[(x.回抽 == a) & (x.前段量 == b)] + rows.append(stat(g, f"{a} × {b}", len(x))) + print(pd.DataFrame(rows).to_string(index=False)) + + print("\n 与信号根量的相关性(防止是同一件事换个说法)") + for col, name in FACTORS: + v = x[[col, "vr60"]].dropna() + print(f" {name:<14} vs vr60 r = {v[col].corr(v.vr60):+.3f}") + + +if __name__ == "__main__": + main()