diff --git a/research/HANDOFF.md b/research/HANDOFF.md index 708b1bc..9470c58 100644 --- a/research/HANDOFF.md +++ b/research/HANDOFF.md @@ -333,15 +333,70 @@ ATR 混淆已排除:`有前序` 的 ATR 确实略高(中位 15.04 vs 13.13bp 四分位分层后 **4/4 层同向**,层内余量差 3.29 / 8.39 / 5.16 / 4.14bp,与不分层 的 +6.06 同量级。且毛R 本身是 ATR 归一化指标,其 +0.19 不可能是 ATR 假象。 -**可用方式**:这 16% 的信号能多容忍约 6bp 执行成本。可用于加仓位权重, -或对这批放宽 ATR 门控——ATR 最低层里 `有前序` 余量仍有 14.92bp(对照 11.63), -提示门控对它们可以更松。**但放宽门控尚未回测,先别改。** +**可用方式**:这 16% 的信号能多容忍约 6bp 执行成本。 + +> ⛔ **「加仓位权重」这条已被 step51 否掉,见 §3.39。** +6bp 余量在 5bp +> 滑点假设下只值约 0.011R,而这批信号 100% 发生在别的币已有仓位时—— +> 加权后峰值并发 7.00 → 8.46、回撤 17.3 → 19.2,按峰值保证金归一是净负的。 +> 边际收益买不起它带来的集中度。 +> +> 仍未测的出路是**放宽 ATR 门控**(多做几笔,而非每笔做大):ATR 最低层里 +> `有前序` 余量仍有 14.92bp(对照 11.63),提示门控对它们可以更松。 +> **尚未回测,先别改。** > ⚠️ **「同分钟 vs 错开」是看过数据之后才划的切法,不是事先定的。** > 208 天、158 个簇,post-hoc 切分容易切出噪声。**拿它当仓位规则前必须换一段 > 时间验证。** 目前可确定的只有「扎堆整体更好」(簇级也成立); > 「好处全集中在错开那一档」尚待样本外确认。 +**❌ 信号根成交量:假设方向是反的,高量入场明显更差(step50)** + +用户假设「有资金的趋势才是好趋势」,预期开仓根成交量越大越好。 +成交量在信号根收盘时可知,符合上面那条纪律,是合法的可交易切法。 +同一批 3941 笔,按信号根相对成交量四分位: + +| vr60 分位(当根量 / 前 60 根均量) | 样本外毛R | 样本外余量 | 发现期毛R | 发现期余量 | +|---|---|---|---|---| +| 量最低(中位 0.63) | **1.421** | **27.18bp** | 1.192 | 20.91bp | +| 量中低 | 1.187 | 20.37bp | 1.133 | 19.80bp | +| 量中高 | 1.067 | 18.69bp | 0.841 | 13.30bp | +| 量最高(中位 5.34) | 0.794 | 13.47bp | 0.634 | 7.94bp | + +**单调递减,且样本内外、两套量比基准(前 10 根 / 前 60 根)全部同向。** +稳健性达到了 step49 那条的标准:**ATR 四分位 4/4 同向**(层内毛R 差 +−0.327 / −0.238 / −0.347 / −0.592),**逐时段 7/7 同向**。不是 ATR 换脸。 + +机制在出场结构里,**伤害集中在止损命中率**: + +| vr60 分位 | 止盈% | 止损% | 超时% | 到 3ATR 减仓率 | 赢时均R | 亏时均R | +|---|---|---|---|---|---|---| +| 量最低 | 33.5 | 22.5 | 44.0 | 77.6% | 1.830 | −1.098 | +| 量最高 | 26.1 | **45.3** | 28.6 | 60.9% | 1.651 | −1.106 | + +亏损幅度四档全是 −1.10(止损就是止损),赢时均R 只降 10%。**止损率翻倍** +是全部损失来源。这里有个判别点:若只是「2 ATR 止损相对突然放大的波动太窄」 +的尺度错配,超时单应按原比例分流进止盈和止损两侧;实际是超时(−15.4pp) +**和止盈(−7.4pp)一起**流进止损(+22.8pp)。**方向本身在变差,不只是止损太窄**, +所以对策是回避而非放宽止损。 + +为什么直觉会反:B4/S4 是**在突破根上进场**的。大量根意味着这一冲已经由别人 +的资金完成了,你在它的收盘价接手——高量不是「资金在推你」,是「你在给推 +完的人接盘」。用户的直觉在中长周期趋势跟随上成立,但这个入场点站在它的 +对面。**「有资金」要能获利,必须在资金到达之前进场,不是同时。** + +与 `有前序` 是两件独立的事,不是同一效应换个说法(相关性很弱:有前序组 +vr10 中位 1.76 vs 无前序 1.46)。2×2 交叉,最好的一档是两者叠加: + +| | 笔数 | 占比 | 毛R | 余量bp | +|---|---|---|---|---| +| 低量 × 有前序 | 253 | 6% | **1.398** | **31.00bp** | +| 低量 × 无前序 | 1717 | 44% | 1.092 | 18.35bp | +| 高量 × 有前序 | 382 | 10% | 1.074 | 19.62bp | +| 高量 × 无前序 | 1589 | 40% | 0.988 | 16.39bp | + +> 尚未做的:把量比做成硬过滤(如砍掉最高四分位)要付 25% 的笔数, +> 而 1m 本来就稀疏(10 币约 2.5 笔/天)。**先当仓位权重用,别当开关。** + **TRX 应从币池剔除**:实盘口径 208 天只有 **5 笔**,ATR 门控几乎全刷掉 (与 §5.3「2026 只剩 4.2%」一致)。实际可用是 10 个币,不是 11 个。 逐币每天笔数:DOGE/AVAX/SOL 约 0.66,BTC/LTC 0.45,BNB 0.31,TRX 0.02。 @@ -624,6 +679,58 @@ ADA 也才 2.41bp。 PF 从 1.59 提到 1.85(§4 step30)。**两个过滤器的相对价值随级别翻转, 不要跨级别套用滤网结论。** +### 3.39 因子化仓位:量因子该做成**开关**不是权重,前序因子根本不该进仓位(step51) + +用户提出把 §3.31 的两个分层结论(`有前序` +6bp 余量、低量根 +14bp 余量) +做成仓位/杠杆因子。做了组合层面的评估,结论与分层表给人的印象**不一致**, +两个因子的命运完全相反。 + +**方法上刻意设的三道约束**(分层结论转仓位规则最容易翻车的地方): + +1. **阈值取整数、权重取整数比,一个参数都不搜。** vr60 切在 1 / 2 / 4, + 权重 1.5 / 1.25 / 0.75 / 0.5。观测到的四分位边界在不同时段并不一致 + (样本外 0.63/1.26/2.28/5.34,发现期 0.62/1.48/3.11/7.97), + 用样本分位数等于在同一批数据上既发现又调参。 +2. **权重归一化到均值 1**,各方案投出去的平均资金相同,均R 才可比。 +3. **判据是四项一起看**:均R / R夏普 / 最大回撤 / **峰值加权并发**。 + 只看均值必然误判——加权一定抬方差,且 `有前序` 按定义就发生在别的币 + 已有仓位时,给它加权就是在敞口最集中的时刻加杠杆。 + +全样本 3941 笔,假定滑点 5bp/taker 腿: + +| 方案 | 盈亏平衡滑点 | R夏普 | 最大回撤R | 峰值并发 | 总R/峰值并发 | +|---|---|---|---|---|---| +| 等权(现状) | 16.7bp | 0.420 | 17.3 | 7.00 | 371.4 | +| 仅量因子(加权) | 18.2bp | 0.442 | 12.7 | 7.98 | 367.4 | +| **仅前序因子** | 17.0bp | 0.421 | **19.2** | **8.46** | **312.6** | +| 两因子相乘 | 18.6bp | 0.446 | 14.3 | 9.48 | 314.6 | +| **✅ 硬砍高量档(vr60 ≥ 4 不做)** | **18.6bp** | **0.496** | **11.2** | **6.00** | **389.3** | +| 硬砍 + 前序权重 | 19.0bp | 0.500 | 11.0 | 7.19 | 332.1 | + +**① `有前序` 不能做仓位因子——它的边际收益买不起它带来的集中度。** +盈亏平衡滑点只 +0.3bp,但峰值并发 7.00 → 8.46、回撤 17.3 → 19.2, +**按峰值保证金归一后是净负的(371 → 313)**。原因是量纲:+6bp 余量在 +5bp 滑点假设下只值约 0.011R,而它 100% 发生在已有并发仓位的时刻。 +§3.31 说的「可用于加仓位权重」**到此作废**——那条余量优势是真的, +但兑现方式不是仓位。(可能的出路是对这批放宽 ATR 门控,即多做几笔而非 +每笔做大,那条仍未测。) + +**② 量因子「不做」优于「少做」。** 加权版 R夏普 0.442、硬砍版 0.496; +回撤 12.7 vs 11.2;峰值并发 7.98 vs **6.00**。硬砍付出笔数 −22% +(3941 → 3061)、总R −10%,换来回撤 −35% 和并发 −14%。 +**并发下降这一点单独就值**:§3.31 已把峰值敞口列为扩币的前置约束, +砍掉高量档等于同时松开这个约束。 + +**③ 优势的形态是「削尾」不是「抬均值」。** 均R 差在各滑点档上几乎恒定 +(0.085 → 0.082),但**基数在塌**,所以相对优势随成本上升而放大: +15bp 处总R/回撤 2.57 → 11.86(4.6 倍),因为回撤从 150.5 掉到 60.3。 +**这两个因子买的是尾部风险,不是收益。** + +⚠️ **可操作口径要用发现期的数,不是全样本。** 盈亏平衡滑点样本外 18.2bp、 +发现期只有 **13.5bp**(硬砍后 15.6bp)——差距就是 2026 的 ATR 压缩(§5.3)。 +发现期才代表当前波动环境。13.5bp 与 `shadow_budget.BUDGET_PORTFOLIO_2026` +的 15.19bp 同量级,互为印证。**影子测量要对标的是 13.5 / 15.6,不是 18.6。** + ### 3.4 alpha 的来源(step32 消融) 逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位, @@ -763,6 +870,7 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算 | 刷交易额换 VIP 费率 | 成本收益不划算,见 5.3。**但前提已变**:该结论算于「原始 taker 6bp、全 taker、TP=3.0、无门控」,四个前提现在全不成立(实际 2/0.8bp、混合费率、分批出场、有 ATR 门控)。已在返 50% 的档位上,继续压缩的空间本就有限,但**若要重估需重跑,不要直接引用旧结论** | step23 | | **按 ATR 从大到小选币 / 追高 ATR 时刻** | 门控之上 ATR 高不加分。**币内严格单调递减**:ATR 最低 20% 分位净均R 1.040,最高 20% 只有 0.878。跨币看似正相关(r=+0.684)但**剔除 BTC 后掉到 +0.469**,其余 9 币极差仅 0.089。ATR 唯一真正给的是 bp 余量(机械关系)。且追高 ATR 会撞上流动性墙——现有 11 币全是头部流动性,外推到小市值币时多出的余量会被滑点吃回去。**ATR 是及格线不是排序键** | §3.33 | | **`available_ts` 改取 `bis[2]`(中枢成立即可用)以消除右边缘重画** | 重画确实从 6.5% 降到 1.2%、滞后 2.16→2.01,**但 alpha 被打成零**:实盘口径毛 R 0.933 → **−0.000**(扣费前就没了 edge),PF 3.22→0.80,余量 15.07→−2.20bp,8/8 币全变差。中枢没发育完就下注,支撑/压力还没立住。**「等中枢最后一笔」就是 alpha 本身,重画是必付代价。**推论:任何以「让信号更早确定」为目标的改动,先测毛 R,不要只看重画率和滞后 | step47 §5.42 | +| **挑「信号根成交量大」的信号(有资金推的趋势)** | **方向完全相反**:vr60 四分位上毛R 单调 1.421→0.794、余量 27.18→13.47bp,样本内外 / 两套量比基准 / ATR 4 分位 4/4 / 逐时段 7/7 全部同向。止损率 22.5%→45.3% 翻倍,且**止盈率同时下降**(说明是方向变差,不是止损太窄)。根因:B4/S4 在突破根上进场,大量根 = 这一冲已经由别人的资金走完,你在收盘价接盘。**低量那一档才是好的**,可反向用作仓位权重 | step50 §3.31 | | 线段(`Chan_XD`)做大级别 | 滞后太大,且中枢极少 | 早期,用户也这么说 | --- @@ -1600,6 +1708,14 @@ API 限流风险隔离三个好处。 | 40 | 第四类买卖点的过滤消融(web 端原始 vs 过滤) | | 41 | **出场口径全扫**(SL × TP × MAX_BARS × 分批 × 剩余半仓止损位 + 真实费率模型)。TP=3 截早了;分批要做但止损保持原位;SL=1.0 最差 | | 42 | **1m 单独定出场**(同一张全网格,11 币 7 年 25476 笔)。1m 右尾是其他级别 2 倍,TP 不能混用;`SL2 分批→8ATR 留损2 48根` 在均R/R夏普/回撤/剔10%PF 四项全赢,滑点余量 11.06→20.12bp;`MAX_BARS=48` 反而不用改 | +| 44 | **1m 过滤消融**:浅色信号毛R 只有深色的 1/6,差在质量不在成本,不能做 | +| 45 | 重画审计脚本(时点重建,深色口径)——待跑 | +| 46 | **引擎逐位对拍基线**(klu/klc/bi/seg/zs/信号/消费列哈希),重构安全网 | +| 47 | **`available_ts` bis[2] A/B** → 作废,毛R 被打成零(§5.42) | +| 48 | 多币开仓时间分布、并发、簇内顺序(首发是未来信息) | +| 49 | **「有前序」样本外验证**(10 币 80 万根)→ 通过,余量 +6bp,6/6 时段同向 | +| 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);**前序因子不该进仓位**,边际收益买不起集中度。优势形态是削尾不是抬均值 | 输出都在 `research/out/`。**2026-08-27 清理过一轮**:step1~20 的输出(早期方法论 已被推翻,含未来函数偏差)与旧口径备份一并删除,只留支撑当前结论的证据。 @@ -1688,3 +1804,14 @@ API 限流风险隔离三个好处。 **这条是扩币的前置条件**——加币不分散,只把峰值敞口推得更高 - [ ] 若要扩币:按 3.33 的两条标准筛(ATR 对门控 + 流动性), 并留一批筛完不看、直接进实盘验证,以保住样本外的说服力 +- [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 信号路径加一行,随时可上,不必现在决定** +- [ ] `有前序` 的余量优势改用**放宽 ATR 门控**兑现(多做几笔而非每笔做大)。 + §3.31 已记,未回测 diff --git a/research/step48_signal_timing.py b/research/step48_signal_timing.py index 5b4563a..2558394 100644 --- a/research/step48_signal_timing.py +++ b/research/step48_signal_timing.py @@ -88,6 +88,11 @@ def collect(sym: str, rows: int) -> pd.DataFrame | None: "r": res[f"{cfg}_r"].to_numpy(), "c": res[f"{cfg}_c"].to_numpy(), "atr_pct": atr / close, + # 信号根的相对成交量。当根已收盘,开仓时可知,是合规的可交易信息。 + # 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], }) except Exception as e: print(f" {sym} 失败: {e!r}", flush=True) diff --git a/research/step50_volume.py b/research/step50_volume.py new file mode 100644 index 0000000..fe1b9d5 --- /dev/null +++ b/research/step50_volume.py @@ -0,0 +1,190 @@ +"""Step 50:信号根的成交量是否预测质量 ——「有资金的趋势才是好趋势」。 + +用户假设:开仓时刻的成交量越大,说明有真金白银在推,趋势更可信。 + +这个假设满足 §3.31 立的纪律:信号根已收盘,其成交量在开仓那一刻可知, +是合规的可交易信息,不像「我是簇里第几个」那样含未来。 + +只测两件事,不做穷举(组合空间大,多测必出假阳性): + 主假设 相对成交量越高,毛R 与滑点余量越好 + 次假设 它与 §3.31 的「有前序信号」是同一件事,还是两件独立的事 + +口径与 step48/49 一致。发现期(2026-01-30 起)与样本外分开报, +主假设若只在其中一边成立就不算通过。 +""" +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 +WIN_MIN = 5 +OUT = HERE / "out" / "step50_volume.feather" +IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai") + + +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 prep(d: pd.DataFrame) -> pd.DataFrame: + from lib.exit_model import fee_of, taker_notional + + d = d.sort_values("date").reset_index(drop=True) + t = d.date.values.astype("datetime64[m]").astype(np.int64) + d["prev"] = np.searchsorted(t, t, "left") - np.searchsorted(t, t - WIN_MIN, "left") + net = d.g.values - fee_of(d.r.values, d.c.values) + d["net"] = net + d["gR"] = d.g.values / (SL * d.atr_pct.values) + d["R"] = net / (SL * d.atr_pct.values) + d["tn"] = taker_notional(d.r.values, d.c.values) + return d + + +def stat(g: pd.DataFrame, lab: str, denom: int) -> dict: + if len(g) < 25: + 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 by_volume(d: pd.DataFrame, col: str, label: str) -> None: + x = d[d[col].notna() & np.isfinite(d[col])] + if len(x) < 200: + print(f" {label}: 样本不足") + return + x = x.copy() + x["bin"] = pd.qcut(x[col], 4, labels=["量最低", "量中低", "量中高", "量最高"]) + 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, f"{col}中位", [med.get(b) for b in t["分组"]]) + print(f"\n--- {label} ---") + print(t.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) + + 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("\n" + "=" * 104) + print("########## 主假设:相对成交量越高越好? ##########") + for lab, part in (("样本外", oos), ("发现期", ins)): + print(f"\n===== {lab} =====") + by_volume(part, "vr10", f"{lab} / 量比 vs 前 10 根") + by_volume(part, "vr60", f"{lab} / 量比 vs 前 60 根(换基准对照)") + + print("\n" + "=" * 104) + print("########## 次假设:成交量与「有前序」是不是同一件事 ##########") + x = d[d.vr10.notna() & np.isfinite(d.vr10)].copy() + x["高量"] = x.vr10 >= x.vr10.median() + x["有前序"] = x.prev >= 1 + print("\n2×2(全样本)") + rows = [] + for hv in (False, True): + for pv in (False, True): + g = x[(x.高量 == hv) & (x.有前序 == pv)] + rows.append(stat(g, f"{'高量' if hv else '低量'} × {'有前序' if pv else '无前序'}", len(x))) + print(pd.DataFrame(rows).to_string(index=False)) + + print("\n" + "=" * 104) + print("########## 混淆排查:量效应是不是 ATR 效应换了张脸 ##########") + y = d[d.vr60.notna() & np.isfinite(d.vr60)].copy() + y["atrQ"] = pd.qcut(y.atr_bp, 4, labels=["ATR-Q1", "Q2", "Q3", "Q4"]) + rows = [] + for q, g in y.groupby("atrQ", observed=True): + g = g.copy() + g["vq"] = pd.qcut(g.vr60, 2, labels=["低量", "高量"]) + lo, hi = g[g.vq == "低量"], g[g.vq == "高量"] + if min(len(lo), len(hi)) < 25: + continue + rows.append({"ATR分位": str(q), "笔数": 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 lo.gR.mean() > hi.gR.mean() else "高量更好"}) + print(pd.DataFrame(rows).to_string(index=False)) + + print("\n########## 逐时段稳定性(与 step49 同一根标尺)##########") + y["半年"] = y.date.dt.to_period("2Q").astype(str) + rows = [] + for p, g in y.groupby("半年", observed=True): + if len(g) < 120: + continue + g = g.copy() + g["vq"] = pd.qcut(g.vr60, 2, labels=["低量", "高量"]) + lo, hi = g[g.vq == "低量"], g[g.vq == "高量"] + rows.append({"时段": p, "笔数": len(g), + "低量毛R": round(lo.gR.mean(), 3), "高量毛R": round(hi.gR.mean(), 3), + "低量余量": round(lo.net.mean() / lo.tn.mean() * 1e4, 2), + "高量余量": round(hi.net.mean() / hi.tn.mean() * 1e4, 2), + "方向": "低量更好" if lo.gR.mean() > hi.gR.mean() else "高量更好"}) + t = pd.DataFrame(rows) + print(t.to_string(index=False)) + if len(t): + print(f"\n{len(t)} 个时段中 {(t.方向 == '低量更好').sum()} 个低量更好") + + print("\n两者的相关性") + print(f" 有前序组 vr10 中位 {x[x.有前序].vr10.median():.2f}" + f" vs 无前序组 {x[~x.有前序].vr10.median():.2f}") + print(f" 高量组里有前序占 {x[x.高量].有前序.mean()*100:.1f}%" + f" vs 低量组 {x[~x.高量].有前序.mean()*100:.1f}%") + + +if __name__ == "__main__": + main() diff --git a/research/step51_factor_sizing.py b/research/step51_factor_sizing.py new file mode 100644 index 0000000..e951766 --- /dev/null +++ b/research/step51_factor_sizing.py @@ -0,0 +1,161 @@ +"""Step 51:把 §3.31 的两个分层结论做成仓位因子,看组合层面到底有没有变好。 + +已知(都用开仓时可知的信息): + step49 `有前序`(过去 5 分钟别的币先发过)→ 滑点余量 +6bp,6/6 时段同向 + step50 信号根低量(vr60 小)→ 余量 +14bp,ATR 4/4 + 时段 7/7 同向 + +但「分层看着好」到「加权之后组合更好」不是一回事,中间有三个坎: + + 1. 两个结论是分开切出来的,相乘等于假设正交且效应可乘。没验过。 + 2. 加权必然抬高方差。均R 涨了但 R夏普 可能不涨,那就白做。 + 3. `有前序` 按定义就是「别的币已有仓位时」,给它加权 = 在敞口最集中的 + 时刻再加杠杆。§3.31 已经把峰值并发列为扩币的前置约束,这里直接顶上去。 + +所以本步的判据不是均R,是这四项一起看:**均R / R夏普 / 最大回撤 / 峰值加权并发**。 + +阈值刻意取整数(vr60 的 1、2、4),权重取整数比,**一个参数都不搜**。 +观测到的四分位边界在不同时段并不一致(样本外 0.63/1.26/2.28/5.34、 +发现期 0.62/1.48/3.11/7.97),用样本分位数等于在同一批数据上既发现又调参。 + +主检验是滑点敏感性:这两个因子的优势主要在**余量**而非毛R, +所以真正该看的是「假定执行成本越高,加权方案是否衰减得越慢」。 +若只在 slip=0 处好、随成本上升优势消失,那这两个因子就不值得做进仓位。 +""" +from __future__ import annotations + +import argparse +import sys +import warnings +from pathlib import Path + +import numpy as np +import pandas as pd + +warnings.filterwarnings("ignore") + +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 +IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai") +SLIPS = (0.0, 5.0, 10.0, 15.0, 20.0) # bp / taker 腿 + + +def w_vol(vr: np.ndarray) -> np.ndarray: + """量比越低给越多。整数阈值 1/2/4,权重 6:5:3:2(均值约 1)。""" + w = np.full(len(vr), 1.0) + w[vr < 1.0] = 1.5 + w[(vr >= 1.0) & (vr < 2.0)] = 1.25 + w[(vr >= 2.0) & (vr < 4.0)] = 0.75 + w[vr >= 4.0] = 0.5 + w[~np.isfinite(vr)] = 1.0 # 前 60 根不足时不表态 + return w + + +def w_prev(prev: np.ndarray) -> np.ndarray: + return np.where(prev >= 1, 1.4, 1.0) + + +def w_cut(vr: np.ndarray) -> np.ndarray: + """硬砍最高量那一档。用来看「不做」和「少做」差多少。""" + w = np.ones(len(vr)) + w[np.isfinite(vr) & (vr >= 4.0)] = 0.0 + return w + + +def peak_concurrent(d: pd.DataFrame, w: np.ndarray) -> float: + """扫描线求峰值同时在场的加权敞口(单位:等权仓位数)。""" + t0 = d.date.values.astype("datetime64[m]").astype(np.int64) + t1 = t0 + d.hold.values + ev = np.concatenate([np.stack([t0, w], 1), np.stack([t1, -w], 1)]) + ev = ev[np.lexsort((ev[:, 1], ev[:, 0]))] + return float(np.max(np.cumsum(ev[:, 1]))) + + +def evaluate(d: pd.DataFrame, w: np.ndarray, slip_bp: float) -> dict: + """slip_bp 只加在 taker 腿上——止盈是限价,不吃滑点。""" + from lib.exit_model import fee_of, taker_notional + + tn = taker_notional(d.r.values, d.c.values) + net = d.g.values - fee_of(d.r.values, d.c.values) - slip_bp * 1e-4 * tn + R = net / (SL * d.atr_pct.values) + + m = w > 0 + if m.sum() < 50: + return {} + ww = w[m] / w[m].mean() # 归一化:各方案投出去的平均资金相同 + wR = ww * R[m] + eq = np.cumsum(wR) + dd = float(np.max(np.maximum.accumulate(eq) - eq)) + return { + "笔数": int(m.sum()), + "均R": round(float(wR.mean()), 3), + "R夏普": round(float(wR.mean() / wR.std()), 3), + "总R": round(float(eq[-1]), 1), + "最大回撤R": round(dd, 1), + "总R/回撤": round(eq[-1] / dd, 2) if dd > 0 else np.inf, + "余量bp": round(float((ww * net[m]).sum() / (ww * tn[m]).sum() * 1e4), 2), + "峰值并发": round(peak_concurrent(d[m], ww), 2), + } + + +def run(d: pd.DataFrame, label: str) -> pd.DataFrame: + vr, pv = d.vr60.values, d.prev.values + schemes = { + "等权(现状)": np.ones(len(d)), + "仅量因子": w_vol(vr), + "仅前序因子": w_prev(pv), + "两因子相乘": w_vol(vr) * w_prev(pv), + "硬砍高量档": w_cut(vr), + } + rows = [] + for slip in SLIPS: + for name, w in schemes.items(): + r = evaluate(d, w, slip) + if r: + rows.append({"时段": label, "滑点bp": slip, "方案": name, **r}) + return pd.DataFrame(rows) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--src", default=str(HERE / "out" / "step50_volume.feather")) + args = ap.parse_args() + + from step50_volume import prep + + d = pd.read_feather(args.src) + 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"实盘口径 {len(d)} 笔 | 样本外 {len(oos)} 发现期 {len(ins)}") + + for lab, part in (("样本外", oos), ("发现期", ins), ("全样本", d)): + t = run(part, lab) + print("\n" + "=" * 118) + print(f"########## {lab} ##########") + for slip in SLIPS: + g = t[t.滑点bp == slip].drop(columns=["时段", "滑点bp"]) + print(f"\n--- 假定滑点 {slip:.0f}bp / taker 腿 ---") + print(g.to_string(index=False)) + + print("\n" + "=" * 118) + print("########## 判据:优势是否随执行成本上升而扩大 ##########") + t = run(d, "全样本") + base = t[t.方案 == "等权(现状)"].set_index("滑点bp") + rows = [] + for name in t.方案.unique(): + if name == "等权(现状)": + continue + g = t[t.方案 == name].set_index("滑点bp") + rows.append({"方案": name, **{ + f"{int(s)}bp处均R差": round(g.loc[s, "均R"] - base.loc[s, "均R"], 3) + for s in SLIPS}}) + print(pd.DataFrame(rows).to_string(index=False)) + + +if __name__ == "__main__": + main()