diff --git a/chanlun/analysis/fast_bsp.py b/chanlun/analysis/fast_bsp.py index fd0e140..6491b5d 100644 --- a/chanlun/analysis/fast_bsp.py +++ b/chanlun/analysis/fast_bsp.py @@ -44,6 +44,27 @@ import pandas as pd from chanlun.core.ChanEnum import Chan_FX_TYPE +# 中枢的「可用时刻」取第几笔的确认时间。 +# -1 = 最后一笔(历史默认)。中枢每吸收一根K线,最后一笔就可能后移, +# 于是 available_ts 跟着漂——这是右边缘重画的根因(见 HANDOFF §5.41)。 +# 2 = 第三笔。三笔重叠即中枢成立,此后不再变,理论上更早也更稳。 +# ⛔ step47 已判定 bis[2] 作废(毛 R 0.933 → −0.000,见 HANDOFF §5.42)。 +# 开关保留只为可复现那次 A/B,**不要改默认值**。 +import os as _os + +AVAIL_BI_INDEX = -1 + + +def _resolve_avail_bi(avail_bi: int | None) -> int: + """优先级:显式入参 > 环境变量 CHAN_AVAIL_BI > 模块默认。 + + 环境变量在调用时读取而非 import 时——ProcessPoolExecutor 在 fork 启动方式下 + 子进程会继承已 import 的模块,import 时读就固化成父进程的值了。 + """ + if avail_bi is not None: + return avail_bi + return int(_os.environ.get("CHAN_AVAIL_BI", AVAIL_BI_INDEX)) + def timestamps_ms(src: pd.DataFrame) -> np.ndarray: """取毫秒时间戳。研究侧的 df 自带 timestamp,web 侧的不一定,故按 date 回退。 @@ -69,7 +90,7 @@ def ensure_timestamp(df: pd.DataFrame) -> pd.DataFrame: return out -def build_htf_zones(df_htf: pd.DataFrame, tf: str, chan=None) -> pd.DataFrame: +def build_htf_zones(df_htf: pd.DataFrame, tf: str, chan=None, avail_bi: int | None = None) -> pd.DataFrame: """算 pure 笔中枢,返回带生效时间的区间表。 available_ts —— 该中枢最早可被使用的时间戳(其确认时刻)。 @@ -82,26 +103,28 @@ def build_htf_zones(df_htf: pd.DataFrame, tf: str, chan=None) -> pd.DataFrame: zs_list = chan.cal_bi_zs_list_pure(chan.bi_list) # 用引擎自己的 dataframe 对齐,避免调用方传入的 df 与引擎内部行数不一致 src = chan.dataframe if getattr(chan, "dataframe", None) is not None else df_htf - return zones_from_zs_list(zs_list, src) + return zones_from_zs_list(zs_list, src, avail_bi=avail_bi) -def zones_from_zs_list(zs_list, src: pd.DataFrame) -> pd.DataFrame: +def zones_from_zs_list(zs_list, src: pd.DataFrame, avail_bi: int | None = None) -> pd.DataFrame: """把已算好的 pure 笔中枢转成区间表。 调用方手工跑过 cal_bi_zs_list_pure 时走这里,免得再算一遍(web 的 analyze_chan 就是这种用法)。 """ ts_of = dict(zip(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"), timestamps_ms(src))) + # 中枢可用时刻取第几笔。在循环外解析一次,别让每个中枢都去读一遍环境变量。 + i = _resolve_avail_bi(avail_bi) rows = [] for zs in zs_list: bis = getattr(zs, "bi_list", []) if not bis: continue - # 中枢可用时刻:构成它的最后一笔被确认之时 - last_bi = bis[-1] - sure_key = str(getattr(last_bi, "sure_time", "") or "") - end_key = str(getattr(last_bi, "end_time", "") or "") + # 笔数不够时退回最后一笔(i=2 需要至少 3 笔才成立) + key_bi = bis[i] if (i == -1 or len(bis) > i) else bis[-1] + sure_key = str(getattr(key_bi, "sure_time", "") or "") + end_key = str(getattr(key_bi, "end_time", "") or "") avail = ts_of.get(sure_key) or ts_of.get(end_key) if avail is None: continue diff --git a/research/HANDOFF.md b/research/HANDOFF.md index a5d5596..1b944a0 100644 --- a/research/HANDOFF.md +++ b/research/HANDOFF.md @@ -267,13 +267,39 @@ push = z_above if direction == 1 else z_below #### 3.33 扩币的筛选标准(两条方向相反的约束) -1. **ATR 分布对门控阈值**(当前 8bp)——小市值币波动大,这条更容易过 -2. **流动性 / 盘口价差**——决定滑点能否压进预算,小市值币更难过 +1. **ATR 分布对门控阈值**(当前 8bp)——够不够扛住固定成本 +2. **流动性 / 盘口价差**——滑点能否压进预算 -**两条方向相反,所以最优区间在中间,不在两端。** 现有 11 币正好演示: -BTC ATR 中位 2026 仅 6.5bp、门控刷掉 58.5%、预算 8.58bp 垫底(**大市值输在 -波动不够**);TRX 2026 门控后只剩 4.2% 的信号,当前环境基本不能做。 -最好的是中间那批:ADA 22.28、DOGE 18.55、XRP 17.04。 +**两条都是及格线,不是「越高越好」的排序依据。** + +⚠️ **「那就做 ATR 最大的」是错的**,两个方向都不支持: + +**币内**(每个币按自己的 ATR 分位)——严格单调,高 ATR 时刻更差: + +| 币内 ATR 分位 | 净均R | R夏普 | +|---|---|---| +| 最低 20% | **1.040** | **0.690** | +| 40-60% | 0.982 | 0.655 | +| 最高 20% | **0.878** | 0.599 | + +ATR 飙升往往意味着行情已走过头,噪声大、容易反抽。 +(但不必加上限门控:最高分位的 0.878 仍然是好交易,砍掉只是白丢机会。) + +**跨币**——看似正相关(ATR vs 净均R r=+0.684),但**剔除 BTC 后掉到 +0.469**, +且其余 9 币的净均R 区间只有 0.935~1.024(极差 0.089)。BTC 自己 0.787, +明显低于所有其他币。BTC 差更可能因为它是最大最有效的市场、结构性机会少, +把它的低 ATR 与低 R 读成因果是过度拟合一个点。 + +**ATR 唯一真正提供的是 bp 余量**(r=+0.85,剔 BTC 后仍 +0.85)。 +但这是**机械关系**——余量按定义随 ATR 缩放。它有用(滑点大致固定在 bp, +余量高就能扛更多滑点),但不代表 ATR 高的币「更赚」。 + +⚠️ **追高 ATR 还会撞上这份数据看不见的流动性墙。** 现有 11 币全是头部流动性; +高 ATR 通常意味着小市值、盘口更薄,多出来的余量很可能被滑点吃回去。 +把在头部币上成立的关系外推到另一个流动性区间,是最容易翻车的那类外推。 + +现有 11 币的实际情况:BTC ATR 中位 2026 仅 6.5bp、门控刷掉 58.5%、 +预算 8.58bp 垫底;TRX 2026 门控后只剩 4.2% 的信号,当前环境基本不能做。 筛选很便宜:拉候选币 1m 数据,只算 ATR 分位数与盘口价差,**不必跑策略** 就能排掉大半。 @@ -282,6 +308,152 @@ BTC ATR 中位 2026 仅 6.5bp、门控刷掉 58.5%、预算 8.58bp 垫底(** 全部为正」。若新币是在同一份历史上筛出来的,它们就不再是样本外。 要保持这个性质,得留一批筛完不看、直接进实盘验证。 +#### 3.34 组合最优解:选币的杠杆很小,币数的杠杆在前 6 个 + +穷举每个 N 的最优子集(1m,门控后,固定日历,只扣手续费不扣滑点): + +| 币数 | 最优子集夏普 | 增量 | 年化R | 若信号独立应有 | 捕获比例 | +|---|---|---|---|---|---| +| 1 | 9.94 | — | 323 | 9.94 | 100% | +| 2 | 13.15 | +3.21 | 611 | 14.06 | 94% | +| 4 | 16.93 | +1.61 | 1174 | 19.88 | 85% | +| 6 | 18.87 | +0.78 | 1672 | 24.35 | 77% | +| **8** | **19.87** | +0.38 | 2188 | 28.11 | 71% | +| 11 | 20.22 | +0.02 | 2690 | 32.97 | **61%** | + +**三条结论:** + +1. **边际收益在 6~8 币后归零。** 8→11 只换来 +0.35 夏普(+1.8%), + 但年笔数多 500+,成本与运维复杂度是实打实的。 +2. ~~**具体选哪几个几乎不重要。**~~ 165 个 8 币组合:最好 19.87(ADA/AVAX/ + DOGE/ETH/LINK/LTC/SOL/XRP)、最差 17.61、中位 18.66,极差 2.26(12%)。 + ⚠️ **这一条已被 §3.37 推翻**——它忽略了盘口冲击。计入后币间差距是 5 倍, + 且排序近乎翻转(AVAX 从必选变成必剔,BTC 反而可用)。**选币很重要。** +3. **相关性吃掉近 40% 的分散化收益**——捕获比例从 100% 单调降到 61%。 + 与 3.31 的「92.7% 同向」一致:加币是加机会,不是加分散。 + +⚠️ **并发保证金不是瓶颈,此前的担心是错的。**(早前按事件平均算出 0.92, +高估了——事件在繁忙时段聚集。时间加权才是对的口径。) + +| | 时间加权均并发 | 有仓时间占比 | 有仓时均并发 | p99 | 最大 | +|---|---|---|---|---|---| +| 全样本 11 币 | 0.15 | 12.3% | 1.24 | 2 | 9 | +| 2026 11 币 | 0.11 | 9.1% | 1.26 | 2 | 8 | + +**资金 88% 的时间闲置**,扩币在保证金维度有大把余地。峰值 9 仍要按 3.31 +的同向假设做限额,但那是尾部风控,不是容量约束。 + +⚠️ **上表夏普只扣手续费、未扣滑点,绝对值虚高(年化 20 不可信), +只能用于相对比较。** 真正的约束是实测滑点 vs 预算——见 5.3 与 3.35。 +按杠杆排序:**滑点是生死(100%)> 币数 1→6(+90%)> 选哪几个(±12%,基本是噪声)**。 + +#### 3.36 成本模型的已知缺口:滑点被当成了常数,但它是仓位的函数 ⚠️ + +**全文所有预算数字都假设滑点是固定 bp,与下单规模无关。这是错的** +(用户 2026-08-28 指出)。且这个错误不是随机的——它系统性偏袒低 ATR 端。 + +固定分数下单意味着 **名义额 = 风险额 / (2·ATR),即仓位 ∝ 1/ATR**, +而预算(bp)∝ ATR。**两者方向相反:** + +| ATR 桶 bp | 中位杠杆 | 中位名义额(10万本金、每笔冒1%) | 预算 bp | +|---|---|---|---| +| 8-12 | 5.0x | **$502,029** | **13.7** | +| 12-16 | 3.6x | $362,362 | 19.5 | +| 16-22 | 2.7x | $269,999 | 23.1 | +| 22-30 | 2.0x | $198,172 | 29.3 | +| >30 | 1.3x | **$129,065** | **52.1** | + +**低 ATR = 单子最大 + 预算最小 + 滑点随单子变大 = 三重挨打。** +所以按固定 bp 算出的逐币/分桶预算,**系统性低估了低 ATR 端的劣势**。 + +✅ **ATR 门控因此比原论证更有价值**:被刷掉的信号中位杠杆 **8.9x、p90 17.6x** +(保留的是中位 3.2x、p90 5.3x)——门控砍掉的正是最大的单子。 +这比「固定成本占比高」是更强的理由。 + +**三条待办(都属于「数据回来前不做、回来后会后悔」):** + +1. **影子交易要记录信号时刻的盘口快照(≥10 档),不只是成交价。** + 有深度就能离线算任意仓位的冲击成本——**一次测量回答所有资金量级**, + 否则换个规模就得重测。若用极小量或 paper 测,得到的只是 + `价差/2 + 漂移` 这个下界,必然乐观。 +2. **资金容量要算出来,不要等实盘暴露。** 预算 20bp 蕴含一个资金上限。 + 中位名义额 $32 万、p90 $53 万,在 ADA/LINK/AVAX 的 1m 永续盘口上不是零头。 +3. **maker 腿的仓位问题是成交率,不是冲击。** 回测假设 3 ATR 与 8 ATR + 的限价单全额成交。$30 万挂单可能只部分成交,**而部分成交会改变分批出场 + 的收益结构**(§3.5 的结论建立在全额成交上)。需在影子数据里验证。 + +#### 3.37 计入盘口冲击后,币种排序几乎翻转 —— §3.34「选哪几个不重要」作废 ⚠️ + +Bitget USDT 永续实时盘口(2026-08-28 01:30 HKT,5 次快照取中位)。 +**净预算 = 回测预算 − $300k 单边吃单冲击**($300k ≈ 10万本金 × 中位杠杆 3.2x): + +| 币 | 价差bp | ask深度 | $300k冲击 | 回测预算 | **净预算** | 冲击占预算 | +|---|---|---|---|---|---|---| +| SOL | 0.09 | $3.3M | 2.82 | 28.3 | **25.5** | 10% | +| DOGE | 1.12 | $13.5M | 4.35 | 28.5 | **24.1** | 15% | +| ETH | 0.04 | $19.4M | 0.27 | 20.8 | **20.5** | 1% | +| XRP | 0.68 | $14.9M | 4.82 | 24.5 | **19.7** | 20% | +| BTC | 0.01 | $4.5M | 0.26 | 14.6 | **14.3** | 2% | +| LINK | 0.84 | $2.8M | 12.20 | 26.2 | 14.0 | 47% | +| ADA | 4.62 | $6.3M | 9.70 | 23.5 | 13.8 | 41% | +| LTC | 1.99 | $3.2M | 11.13 | 23.6 | 12.5 | 47% | +| AVAX | 1.33 | $3.0M | 23.65 | 29.0 | **5.3** | **82%** | + +**§3.34 的「165 个 8 币组合极差仅 12%、选哪几个不重要」是错的**——那个结论 +完全建立在忽略流动性上。计入冲击后币间差距是 **5 倍**(25.5 vs 5.3)。 + +**排序近乎翻转,根因是 ATR 与流动性负相关**,所以按预算(∝ATR)排出的序 +恰好是按流动性排的逆序: + +- **AVAX 原始预算最高(29.0)、净预算最低(5.3)**。它在最优 8 币里入选频率 + 95%,实际最该剔除。5 次快照 22.3~24.7bp,不是抽样噪声 +- LINK / ADA / LTC(入选频率 95%/95%/70%)净预算全掉到 12~14 +- **BTC 此前判为「中性」,实际净预算 14.3,反而优于 LINK/ADA/LTC**—— + 原始预算最低但冲击几乎为零 + +这就是 §3.33 定性警告过的「流动性墙」,现已量化,且比预想严重。 + +✅ **真正的结论:可用币种是资金量的函数,不存在固定的最优币表。** +按 $100k 名义额(≈$32k 本金)重算,净预算回到 ADA 18.0 / LINK 17.2 / +LTC 16.5 / AVAX 11.6——除 AVAX 外全部健康。**扩币前先定本金。** + +**当前运行点:本金 < $2k(用户 2026-08-28 确认,目的是先跑通流程)。** +在这个规模上 **§3.37 的重排序不适用**——单笔仅 $6.4k,冲击占预算 1~8% +(连 AVAX 也只有 8%),**9 个币全部可用,§3.34 的「选哪几个不重要」重新成立**。 +分水岭在 **$20k~$50k 本金**:以上才需要收缩到 SOL/DOGE/ETH/XRP/BTC +(这 5 个在 $2k~$200k 全区间冲击 ≤21%,是与资金量无关的安全核心)。 + +冲击占预算比例(按本金,杠杆 3.2x): + +| 币 | $2k | $10k | $20k | $50k | $100k | +|---|---|---|---|---|---| +| BTC / ETH | 0% | 0% | 0% | 0% | 0~2% | +| SOL | 1% | 3% | 4% | 5% | 6% | +| XRP / DOGE | 5~6% | 9~10% | 12% | 15% | 16~17% | +| LINK | 4% | 11% | 16% | **31%** | **46%** | +| LTC | 12% | 22% | **30%** | **41%** | **57%** | +| ADA | 20% | **28%** | **29%** | **38%** | **51%** | +| AVAX | 8% | **28%** | **43%** | **70%** | **91%** | + +✅ **小资金无操作性障碍**:Bitget 最小下单量在 $300 本金都不卡, +半仓取整误差最差 2.4%(ETH @ $500),其余 <1%。 + +⚠️ **但 $2k 跑通 ≠ 验证了策略容量。** 小资金下有两处系统性偏乐观、且不可外推: +(a) 冲击≈0,测出的滑点只剩 `价差/2 + 延迟漂移`,几乎必然通过 15.19bp, +但不能推出 $50k 也通过;(b) **maker 腿成交率也是假的好**——$3.2k 挂单几乎 +必然全成,$150k 不一定,而 §3.5 分批出场的收益结构全建立在全额成交上。 +**→ 这正是「记录盘口快照」的价值:它是唯一能让小资金数据回答大资金问题的东西。** +只记成交价的话,每上一个资金台阶都得重测一遍。 + +$2k 阶段真正该盯的只有两件:**延迟与漂移**(与规模无关,结论长期有效)、 +**中枢阶梯 + ATR 门控必须在线上生效**(否则测的是另一批信号,与回测不可比)。 + +⚠️ **三条保留**:(1) 单一时段快照,盘口有日内周期,需跨时段采样; +(2) **信号恰在波动放大时触发,那时盘口更薄——上表是乐观下界**; +(3) 只算了入场 taker 腿,止损/超时同为 taker,maker 腿则是成交率问题。 +第 (2) 条意味着这批静态数字**不能替代影子数据**,只是把「该测什么、该剔谁」 +提前定下来了。 + ### 3.35 跨 venue:信号身份不迁移,但总体期望迁移(live 前置测量) **回测全部跑在 Binance 数据上,实盘要在 Bitget 成交。** 服务器侧的 @@ -314,6 +486,39 @@ BTC 5.87 vs 6.09、ETH 10.02 vs 12.06、SOL 8.92 vs 9.87bp。 ⚠️ 上面那批数字用的是 `毛均收益 − 6.0bp`、只有同向过滤、`TP=3.0`, **不能直接当预算用**,正确的逐币预算见 `lib/shadow_budget.py` 与 5.3。 +### 3.38 浅色信号(未过双过滤)在 1m 上不能做——差在毛质量,不在成本(step44) + +用户观察「图上很多浅色 b4/s4 的入场价看着也很有优势」。step40 做过同类消融但 +只覆盖 15m/30m 且用旧口径(5bp 平摊、1.5/3.0/48),1m 从未验证。 + +`research/step44_filter_ablation_1m.py`,8 币 × 30 万根,当前最优 1m 出场 +(SL2.0 / 3ATR 减半 / runner 8ATR / rstop 留原位 / 48 根),真实费率, +ATR ≥ 8bp 门控后共 2302 笔: + +| 口径 | 笔数 | 胜率 | 毛R | 净均R | 剔10%PF | 滑点余量bp | +|---|---|---|---|---|---|---| +| **深色**(同向 ∧ 阶梯) | 955 | 68.3% | **0.933** | 0.798 | 2.35 | **15.07** | +| **浅色**(任一不过) | 1347 | 44.3% | **0.155** | 0.009 | 0.74 | **0.10** | +| — 只过阶梯 | 445 | 52.1% | 0.419 | 0.279 | 1.03 | 4.18 | +| — 只过同向 | 265 | 38.1% | **−0.029** | −0.176 | 0.59 | **−2.15** | +| — 两个都不过 | 637 | 41.4% | 0.046 | −0.103 | 0.62 | −1.64 | + +**关键是「毛R」列——那是扣任何成本之前的数字。** 浅色 0.155 vs 深色 0.933, +**差 6 倍**。所以不存在「信号本来不错、被手续费吃掉」这回事:浅色的跟随性 +本来就差。浅色扣完手续费只剩 **0.10bp**,任何滑点都会让它亏;逐币 8 个里 +4 个已为负(ETH −1.98 / LTC −2.93 / DOGE −1.05 / XRP −0.14),最好的 +ADA 也才 2.41bp。 + +**为什么肉眼看着有优势**:(1) 入场价确实是好的,信号标的是真实局部结构; +(2) 但价格不跟随,毛R 0.155 覆盖不了 2 ATR 的止损——**入场点好 ≠ 价格继续 +朝你走**;(3) 44.3% 的胜率意味着近一半确实走对,扫图时看到的就是这些, +走错的当时形态一样好看。 + +⚠️ **新发现:1m 上阶梯过滤远比同向过滤重要,与 30m 相反。** +只过阶梯 +4.18bp,只过同向 **−2.15bp 且毛R 为负**;而 30m 上同向单独就能把 +PF 从 1.59 提到 1.85(§4 step30)。**两个过滤器的相对价值随级别翻转, +不要跨级别套用滤网结论。** + ### 3.4 alpha 的来源(step32 消融) 逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位, @@ -451,6 +656,8 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算 | 同一中枢重复入场(二次、三次) | 质量骤降,**只做首次** | step25 | | 缠论引擎原生 `find_all_bsp` 的 B3/S3 | 统计上呈逆势、显著亏损:胜率 27.4%(低于 SL1.5/TP3.0 的随机基准 33%)、PF 0.66、t −18.76。**不是滞后造成的**——同一组过滤器把 B4 从 1.59 提到 2.26,对它无效(0.66→0.71),且入场后移 1~4 根只是平滑衰减。它几乎不筛(627 个中枢发 625 个信号),把「价格早已离开、再没回来」的历史区间也当信号发出来。B4/S4 因此单独立类,不是它的提前版 | step30/31 | | 刷交易额换 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 | | 线段(`Chan_XD`)做大级别 | 滞后太大,且中枢极少 | 早期,用户也这么说 | --- @@ -707,6 +914,106 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算 比 15m/30m 的 step38 结果还干净(那里有 0.7% 假阳性)。耗时对窗口严格线性 (约 0.105ms/根),所以没有任何理由带更长的历史。 +⚠️ **「假阳性 0%」是 n=180 的结果,不足以排除 0.5% 量级的重画** +(2026-08-28 更正)。若真实率为 0.5%,180 笔里期望仅出现 0.9 次—— +**0% 与 0.5% 在该样本量下无法区分**。 + +实测反例:web 端 BTC 1m(10 万根窗口),同一份数据分别截到本地 22:25 与 +02:08 两个视角,212 个信号中 **1 个消失**(08-27 22:19 的 B4,约 0.47%), +与 15m/30m 的 0.7% 同量级。用户在界面上先看到、后消失,即此现象。 + +**缓解事实:消失的那个 `htf_agree=False, ladder_ok=False`,是浅色 b4, +本就不交易。** 真正要回答的是「**双过滤通过的深色信号会不会重画**」—— +step39 报的「同向过滤也成立 100%」同样基于 n=180,同样不足以排除低频事件。 + +⚠️ **step39 的「假阳性」测法本身是无效的**:它从全序列随机抽非信号点,看时点 +重建会不会凭空冒信号。1m 信号密度约 474 根 1 个,180 个随机点期望只撞上 +0.38 个——**测出 0% 几乎不含信息量**。重画只发生在「差一点就成型」的结构附近, +不在随机点上。 + +#### 5.41 重画的根因:`available_ts` 取的是中枢**结束**而非**形成** ⚠️⚠️ + +代码审计(比逐根跑数据快得多):`find_fast_bsp3` 给定 `zones` 后严格因果 +(只用 `close[j]` / `high[j-1]`),加未来K线动不了已成型的入场点。 +**重画只可能来自 `zones` 表被修订。** + +`zones_from_zs_list` 里: + +```python +last_bi = bis[-1] # ← 中枢的最后一笔 +avail = ts_of.get(sure_key) or ... # available_ts = 它的确认时刻 +``` + +**用户 2026-08-28 指出的缠论定义问题**:中枢**形成**只需三笔有重叠区间; +**结束**才需要「离开且不返回的笔」确认。判信号只需形成,不必等结束。 +而 `bis[-1]` 只有中枢走完才知道是哪一笔——**取的是结束时刻**。 + +实测(BTC 1m,30 万根,1651 个中枢): + +| 中枢笔数 | 占比 | +|---|---| +| 恰好 3 笔 | 25.5% | +| **>3 笔** | **74.5%**(中位 5、均值 8.1、最大 89) | + +**「最后一笔确认」比「第三笔确认」晚:中位 62 分钟、p75 142、p90 247、最大 1739。** +1m 上 62 分钟就是 62 根 K 线。 + +**三个后果:** + +1. **这就是重画源。** 中枢每吸收一笔,`bis[-1]` 就后移一次。300 时点抽样、 + 3284 个「当时已生效」的中枢:完全一致 84.4%,**确认时刻被改 6.5% + (推后 198 个 vs 提前 17 个,中位 +100 分钟、p90 +413)**,中枢消失 9.0%, + 阶梯翻转 2.6%。用户看到的 08-27 22:19 B4 消失即此。 +2. **它解释了 docstring 里那个 34%。** 「627 个中枢只认 212 个(34%),被拒的 + 多数是『中枢确认时价格早已离开、此后再没回来』」——**「价格早已离开」不是 + 市场现象,是这个定义造出来的**:中枢正是被离开笔终结的,所以扫描起点天然 + 落在离开之后;代码又要求 `was_inside`,于是只能等价格**再次回到中枢再突破 + 一次**。那 66% 多半不是没机会,是第一次离开时还没开始看。 +3. **方向对回测有利、对实盘不利。** 全量的 `available_ts` 系统性更晚 → + 回测扫描起点比实盘晚 → **回测偏保守,不是被高估**;但实盘会产出更多、更早 + 的信号,这部分质量不在回测统计里。 + +**关键性质:`bis[2]` 是不变量,`bis[-1]` 不是。** 用户 2026-08-28 补充的 +判断——「只有没确认的才会重画,确认的都不会」——与实测一致:6.5% 的改动 +**不是已确认的笔被推翻**,而是中枢又吸收了新笔、`bis[-1]` 变成了另一根笔。 +原来那根笔本身没变。而中枢一旦由三笔构成,第三笔就固定,不随延伸改变。 + +**A/B 实测(BTC 1m,300 时点,3284 个已生效中枢):** + +| 口径 | 完全一致 | 确认时刻被改 | 中枢消失 | 改动中位 | +|---|---|---|---|---| +| 现行 `bis[-1]`(中枢结束) | 84.4% | **6.5%** | 9.0% | +100 分 | +| 改用 `bis[2]`(中枢形成) | 89.7% | **1.2%** | 9.0% | +16 分 | + +**`bis[2]` 把该重画源压到 1/5。** 两种口径全量中枢数都是 1651——纯粹改变 +「何时可用」,不增删中枢。 + +**改成「第三笔确认」的预期收益**:消除该重画源(第三笔确认后不再变)、 +符合缠论定义、滞后大幅下降(§3.2 已证滞后是收益第一驱动)。 + +⚠️ **剩下 9.0% 的「中枢消失」两种口径完全相同,是另一个源,与笔确认无关**, +且在改用 `bis[2]` 后成为主要重画来源。最可能是**窗口左边界效应**:2000 根窗口 +截断笔结构,造出全量视角里不存在的中枢。 + +⚠️⚠️ **由此牵出一个此前未记录的口径差:回测用全量历史建中枢,实盘用 2000 根 +窗口。** 若那 9% 确由左边界造成,则它不是重画,而是**回测与实盘看到的中枢集合 +本就不同**——比重画更根本。**待查。** + +⚠️ **但不要直接改。** 这会换掉整个信号总体,本文档所有数字都绑定当前实现。 +反向风险明确:中枢形成即可用意味着会去做「仍在形成中」的中枢的突破, +价格可能再回来——**信号变多但质量可能变差**。必须 A/B 实测。 + +> ⛔ **A/B 已跑完(step47,2026-08-28):`bis[2]` 作废,不要再试。** +> 详见 §5.42。它把重画压到 1/5 的同时,把毛 R 从 0.933 打到 −0.000—— +> **alpha 归零,不是成本问题。** 下面这段「待办」保留仅作推理记录。 + +**(已完成)`available_ts = bis[2].sure_time` vs `bis[-1].sure_time` 的完整 A/B** +(信号数、滞后、PF、剔10%PF、滑点预算),以及深色信号的 +大样本重画审计(脚本已写:`research/step45_repaint.py`)。 + +对实盘的影响有限但真实:实盘会比回测多开极少量的仓,这部分质量不在回测统计里。 +**但出场(止损/止盈/超时)不依赖信号是否仍在图上,不会出现「信号消失、仓位卡住」。** + **这一步顺带解决了一个更重要的问题**:1m 的回测口径本身是可信的, `is_sure` 回撤对 1m 同样没有影响。**1m 现在唯一的风险就剩执行成本。** @@ -721,10 +1028,164 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算 用 **WebSocket 订阅**而非轮询 REST 拉 K 线,并尽快把实盘迁到境外 VPS。 否则测到的滑点全是代理的账。 +#### 5.42 A/B 判决:`bis[2]` 把 alpha 打成零,重画是必付代价(step47)⛔ + +8 个样本外币 × 30 万根 1m,两组共用同一个 TF_DF,只切 `available_ts` 的取法。 +实盘口径(深色 ∧ ATR≥8bp): + +| | `bis[-1]` 现行 | `bis[2]` 中枢成立 | +|---|---|---| +| 笔数 | 955 | 989 | +| 滞后 | 2.16 | 2.01 | +| 胜率 | 68.3% | 40.2% | +| **毛 R** | **0.933** | **−0.000** | +| 净均 R | 0.798 | −0.147 | +| R 夏普 | 0.532 | −0.107 | +| PF | 3.22 | 0.80 | +| 剔10% PF | 2.35 | 0.55 | +| 滑点余量 | **15.07bp** | **−2.20bp** | + +**判决依据是「毛 R 0.933 → −0.000」这一行**:扣任何费用之前 edge 就没了, +所以不是成本、不是门控、不是出场参数的问题,是信号本身不再有预测力。 +逐币 8/8 全部变差,只有 BTC 勉强为正(0.617 → 0.116)。 + +**为什么**:`bis[2]` 只要三笔重叠就认为中枢可用,此时中枢尚未发育, +支撑/压力还没立住。`bis[-1]` 那段「等待」不是可以优化掉的延迟——**它就是 alpha 本身**。 + +由此得到一条一般性结论,值得推广到别的"降延迟"想法上: + +> 右边缘重画不是 bug,是这个信号质量的**必付代价**。任何以「让信号更早确定」 +> 为目标的改动,都要先测毛 R,**不能只测重画率或滞后**。 +> §5.41 只测了稳定性就给出「唯一能同时改善收益与稳定性」的判断,是过早的。 + +顺带交叉验证:现行口径本次算出滑点余量 **15.07bp**,与 §5.3 里 2026 组合口径的 +15.19bp 基本吻合——两条独立路径得到同一个数,该预算值可信。 + +脚本 `research/step47_avail_bi_ab.py`;开关在 `chanlun/analysis/fast_bsp.py` +的 `AVAIL_BI_INDEX`(环境变量 `CHAN_AVAIL_BI`),**默认 −1,保持现行口径**。 + +### 5.5 增量更新已验证:一致且快 12 倍(2026-08-28) + +`pipeline/builders/incremental.py` 此前一直标着「需重新验证」。已验完。 + +**一致性:400 根、每 20 根与全量重算对拍一次(共 20 次), +笔数 / 中枢 zg·zd / 信号 entry_idx 全部相同,无漂移。** + +**耗时(本机 arm64 单线程,1m 2000 根窗口):** + +| 环节 | 增量 | 全量重算 | | +|---|---|---|---| +| TF_DF / `append_bar` | 14.0ms | 192.5ms | | +| 中枢 + `find_fast_bsp3` | 7.6ms | 10.1ms | | +| **1m 侧合计** | **21.7ms** | 202.6ms | 9.3x | +| 5m 侧(`append_bar` 7.1 + 分型线 2.9) | 10.0ms/次 | 84.4ms | 每 5 根一次 → 摊 **2.0ms** | +| **完整一根** | **≈24ms** | **286ms** | **≈12x** | + +⚠️ **必须定期重建窗口,否则增量的优势会被自己吃掉。** +`append_bar` 不是 O(1)——`pd.concat`、`add_indicators`、`cal_bi_list` 三处 +仍是全量 O(n),而 dataframe 只增不减: + +| | 1200 根后行数 | 中位 | 前100根 → 后100根 | +|---|---|---|---| +| 不裁剪 | 3200 | 16.3ms | 13.9 → **19.4(+40%)** | +| **每 500 根重建** | 2199 | 14.4ms | 14.2 → **14.0(持平)** | + +`IncrementalBuilderMixin` 没有裁剪接口,做法是**每约 500 根用最近 2000 根 +重新 `init_stream`**。单次约 192ms,摊到每根 0.4ms,可忽略。 + +**下一个数量级的线索(用户 2026-08-28 指出)**:`add_indicators` +算了很多本策略用不到的指标。它在 `append_bar` 里是**每根全表重算**的, +所以浪费不是一次性的,而是每分钟一次、且随窗口长度线性放大。 +改它会动到 `dataframe` 的列集合,web 与回测都依赖,需整体评估。 + +> 后续实测(见 §5.6)**推翻了这条线索的份量**:`add_indicators` 只占全量 +> 构建的 1.3%,talib 是向量化 C,便宜。真正的浪费在 `ChanKLU.set_indicators` +> 和 `ChanKLC.cal_all_ema_status`。方向对(确实有大量无用计算),位置错了。 + +**这是延迟这块唯一有数量级收益的方向**:`find_fast_bsp3` 本身只占 0.75ms +(全链路的 0.3%),优化信号代码毫无意义;95% 的时间花在 TF_DF 从头重建 +2000 根上,而实盘每分钟只新增 1 根——**重算了 1999 根没变的东西**。 + +> 服务器侧实测 1m 那条腿 695ms(本机同口径 202.6ms,慢 3.4x)。若两条腿 +> 同比例,那边完整链路约 980ms,**已超 §6 定的 800ms lag 告警线,且尚未计 +> 任何网络耗时**。改用增量后应降至约 82ms。⚠️ 该外推假设两条腿慢的比例一致, +> 服务器侧应直接实测一次完整链路。 +> +> **§5.6 优化后本机复测(同口径 2001 根、单线程、从头算一遍)**: +> +> | 口径 | 优化前 | 现在 full | 现在 lean | +> |---|---|---|---| +> | 1m 腿 | 202.6ms | **71.8ms** | **50.0ms** | +> | 完整链路(两腿+信号+过滤) | 286ms | **178.4ms** | **120.5ms** | +> | 增量 `append_bar` | 24ms/根 | — | **13.6ms/根** | +> +> 按 3.4x 换算到服务器:完整链路 980ms → 约 **611ms(full) / 413ms(lean)**, +> **即使完全不上增量也已落回 800ms 告警线以内**;上增量约 46ms。 +> ⚠️ 但 3.4x 是在**优化前的代码**上量的。优化把工作从逐行 Python 对象操作挪到 +> numpy 批量操作,若服务器慢在 CPU 主频则比值成立,若慢在内存带宽则未必。 +> **服务器侧必须直接实测,不要只信这个换算。** + > 为什么这一步必要:step38 只验证了 15m/30m,从没验证 1m。而窗口 4000 根 > 对 15m 是 41 天、对 1m 只有 2.8 天,中枢的左边界效应完全不是一个量级。 > 窗口同时牵动两头:太短则中枢被截断信号不符,太长则算得慢延迟大滑点高。 +### 5.6 引擎提速 2.6~3.6x,逐位一致(2026-08-28) + +先建了 `step46_engine_parity.py` 作为安全网:对 5 个用例(BTC 1m/5m、ETH 5m、 +SOL 15m、XRP 30m,各 2 万根)固化 klc/笔/中枢/信号/被消费列的哈希。 +**没有它不要碰引擎**——行为变化是静默的,不报错、不崩,只是信号悄悄换一批, +而 HANDOFF 全部数字都绑在当前实现上。 + +三处改动,每处都过了对拍: + +| 改动 | 原因 | 效果 | +|---|---|---| +| `cal_kl_data` 预取 ndarray,不再 `df.iloc[i]` | 每根新建 40 列 Series + 在其上做几十次逐键查找 | 这一处就占构建的 **96%**,2 万根 1946ms → 824ms | +| `ChanKLC.cal_all_ema_status` 改惰性 | 每 KLC 立即重算,占 25%,而**全仓无任何读取方** | 调用 14322 → 2248 次 | +| 删 `get_klc_list` 里的 `ema_up_list/ema_down_list` | 累加一整轮后直接丢弃,纯死代码 | — | + +另外加了 `TF_DF(..., lean=True)`:只构建到中枢,跳过线段/走势中枢/整套 MACD +状态机(这些只服务 `bsp_list` 和 web 展示,笔与中枢不依赖)。 +**已验证 lean 与 full 的笔/中枢/信号在 5 个用例上完全一致。** + +结果(2 万根 5m):**full 1946 → 754ms(2.6x),lean → 543ms(3.6x)**。 +增量路径在两种模式下都仍与全量逐位一致;web API 全字段正常(full 模式一字未动)。 + +验证做了四层,每改一处都重跑: + +1. **对拍基线**(上表所列全部哈希项),full 模式三处改动后全部一致 +2. **lean ≡ full**:笔/中枢/信号在 5 个用例上完全相同 +3. **增量路径**:`init_stream` + `append_bar` 追加 150~200 根 vs 全量重建, + lean/full 两种模式都逐位一致 +4. **web 冒烟**:真实 HTTP 请求,`bsp_list` 168 / `seg_list` 177 / `zs_list` 17 / + `klc_trend` 齐全 + +**大样本对拍(20 万根 × 5 用例)**:用 `git stash` 切回改动前的原始代码存基线, +再切回优化版对拍,**逐位一致**。这是真正的改前/改后比对,不是自己跟自己比。 +同一批工作量 **99.9s → 43.3s(full 模式 2.3x)**,与 2 万根时的 2.6x 一致, +说明提速不随规模衰减。复现: + +```bash +git stash push -- chanlun/ +python step46_engine_parity.py --save --rows 200000 --out step46_baseline_big.json +git stash pop +python step46_engine_parity.py --check --rows 200000 --out step46_baseline_big.json +``` + +关键认识订正: + +- **缩放是线性的,不是 O(n²)**。中途一度量到"数据 6 倍、耗时 44 倍",是我自己 + 开的 `tracemalloc` 污染了计时。干净重测每翻倍 ×2.06~2.20,16 万根 lean 4.2s。 +- **`add_indicators` 只占 1.3%**,删无用指标在这里几乎省不到时间。 + 它有 11 个列(`bbup365`/`bblow30`/`bbp302` 等)Python 与前端都无人读取, + 但它们不进 KLU,唯一成本是 web 响应体积(约占 45 列中的 11 列)。 +- 实盘延迟已不是瓶颈:`append_bar` 约 32ms/根,对 800ms 告警线有 25 倍余量。 + **继续优化只对研究吞吐有意义。** + +剩余热点(lean、8 万根口径):`cal_trend` 与 `set_indicators_from` 各约 0.36~0.59s。 +`cal_trend` 不能跳过——`bi.py:221` 读 `klc.trend`,笔的计算依赖它; +它带序列状态(`last_trend` + 近 5 根窗口),向量化风险高,收益约 20%,暂不做。 + --- ## 6. 接下来要做的事(按优先级) @@ -1076,6 +1537,21 @@ API 限流风险隔离三个好处。 - [x] **确认交易所与框架** → Bitget + Hummingbot,先只上 1m - [x] **测执行延迟** → 上游连接器 bug 已定位并修好,补丁后 lag 506~642ms、 无条件漂移 0.50~0.87bp,占预算 6%。见 §6 第 2 步 +- [ ] **实盘信号计算改用增量**(§5.5,已验证一致且快 12x:286ms → 24ms)。 + **配套必须做窗口裁剪**:每约 500 根用最近 2000 根重新 `init_stream`, + 否则 `append_bar` 的 O(n) 部分会让耗时持续爬升(1200 根后 +40%) +- [ ] **`available_ts` 改用 `bis[2]`(中枢形成)而非 `bis[-1]`(中枢结束)的 + 完整 A/B** —— ✅ **已完成,结论是不换**(step47,见 §5.42)。 + 当时预判的「反向风险:会去做仍在形成中的中枢的突破,质量可能变差」 + **正是实际发生的事**,且比预期严重:毛 R 0.933 → −0.000,8/8 币全变差。 + **这条已关闭,不要重开。** +- [ ] **查那 9.0% 的「中枢消失」是不是窗口左边界效应**(§5.41)。若是,则 + 「回测用全量历史建中枢、实盘用 2000 根窗口」是比重画更根本的口径差。 + 注意这条与 `bis[2]` 无关,**没有被 step47 否掉**,仍然要查 +- [ ] **深色信号大样本重画审计**,脚本已写:`research/step45_repaint.py` + (逐根时点重建,替换掉 step39 那个随机抽点的无效测法)。 + §5.42 之后这条的意义变了:不再是为了「修掉重画」,而是为了**量化实盘 + 会多开多少仓、那部分的质量如何**——重画本身已确认是必付代价 - [ ] **写 V2 controller + 盘口缓冲,跑影子测量**(当前第一件事)。 字段与统计口径见 §6 第 3 步,**三条硬要求别漏**:只统计过滤后的信号根、 条件漂移与无条件漂移分开报、出场腿按 maker/taker 分开统计 diff --git a/research/step44_filter_ablation_1m.py b/research/step44_filter_ablation_1m.py new file mode 100644 index 0000000..1cac3db --- /dev/null +++ b/research/step44_filter_ablation_1m.py @@ -0,0 +1,188 @@ +"""Step 44:1m 上「浅色信号(未过双过滤)到底能不能做」。 + +用户观察:图上很多浅色 b4/s4 的入场价看着也很有优势。这在 1m 上必须实测—— +入场点「看着漂亮」和「扣完成本还剩正收益」之间隔着很大距离,而 1m 的固定成本 +相对 ATR 特别重,正是最容易把视觉上的优势吃干净的级别。 + +step40 做过同类消融,但只覆盖 15m/30m,且用旧口径(5bp 平摊成本、SL/TP/超时 +1.5/3.0/48)。1m 的成本压力与之完全不是一个量级,结论不能外推。 + +本步按当前最优 1m 口径复核: + 出场 SL 2.0 / 3 ATR 减半 / runner 目标 8 ATR / runner 止损留在原位 / 48 根超时 + 成本 taker 2bp、maker 0.8bp,滑点只加在 taker 腿(见 lib/exit_model) + 门控 ATR >= 8bp + +把信号按两个过滤标志切成四桶,看浅色那三桶扣费后是正是负。 +""" +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) + +LTF, HTF = "1m", "5m" +SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48 +GATE_BP = 8.0 + + +def collect(sym: str, rows: int) -> pd.DataFrame | None: + import warnings as _w + _w.filterwarnings("ignore") + sys.path.insert(0, str(HERE)) + sys.path.insert(0, str(HERE.parent)) + + from chanlun import TF_DF + from chanlun.analysis.fast_bsp import ( + add_zone_ladder, attach_htf_agree, attach_zone_ladder, + build_htf_zones, find_fast_bsp3, htf_fx_timeline, + ) + from lib.data import fetch_ohlcv + from lib.exit_model import cfg_name, walk_exits + + try: + df = fetch_ohlcv(f"{sym}/USDT:USDT", LTF, rows) + if df is None or len(df) < 50_000: + return None + chan = TF_DF(df, 1, LTF) + cdf = chan.dataframe + zones = add_zone_ladder(build_htf_zones(cdf, LTF, chan=chan).reset_index(drop=True)) + if zones.empty: + return None + sig = find_fast_bsp3(cdf, zones) + if sig.empty: + return None + + df_h = fetch_ohlcv(f"{sym}/USDT:USDT", HTF, 10 ** 9) + chan_h = TF_DF(df_h, 1, HTF) + tl = htf_fx_timeline(chan_h, chan_h.dataframe) + + sig = attach_htf_agree(sig, cdf, tl) + sig = attach_zone_ladder(sig, zones) + + res = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB], + scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,)) + cfg = cfg_name(SL, RUNNER, MAXB, RSTOP) + need = [f"{cfg}_g", f"{cfg}_r", f"{cfg}_c", f"{cfg}_b"] + if any(c not in res.columns for c in need): + return None + + idx = sig["entry_idx"].to_numpy().astype(int) + atr = cdf["atr"].to_numpy(float)[idx] + close = cdf["close"].to_numpy(float)[idx] + out = res[need].copy() + out.columns = ["g", "r", "c", "b"] + out["sym"] = sym + out["atr_pct"] = atr / close + out["htf_agree"] = sig["htf_agree"].to_numpy() + out["ladder_ok"] = sig["ladder_ok"].to_numpy() + out["date"] = cdf["date"].to_numpy()[idx] + return out + except Exception as e: + print(f" {sym} 失败: {e!r}", flush=True) + return None + + +def describe(g: pd.DataFrame, label: str) -> dict: + from lib.exit_model import FEE_MAKER, FEE_TAKER, TP, fee_of, taker_notional + + if len(g) < 40: + return {"口径": label, "笔数": len(g), "备注": "样本不足"} + gross = g["g"].to_numpy() + reason, scaled = g["r"].to_numpy(), g["c"].to_numpy() + fee = fee_of(reason, scaled) + net = gross - fee # 未扣滑点:留出的就是滑点余量 + R = net / (SL * g["atr_pct"].to_numpy()) + gR = gross / (SL * g["atr_pct"].to_numpy()) + w, o = net[net > 0], -net[net <= 0].sum() + q = np.quantile(net[net > 0], 0.90) if (net > 0).any() else 0.0 + t10 = net[(net > 0) & (net <= q)].sum() + tn = taker_notional(reason, scaled) + return { + "口径": label, "笔数": len(g), + "胜率": f"{(net > 0).mean() * 100:.1f}%", + "毛R": round(gR.mean(), 3), + "净均R": round(R.mean(), 3), + "R夏普": round(R.mean() / R.std(ddof=1), 3), + "PF": round(w.sum() / o, 2) if o > 0 else np.inf, + "剔10%PF": round(t10 / o, 2) if o > 0 else np.inf, + "滑点余量bp": round(net.mean() / tn.mean() * 1e4, 2), + } + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--symbols", default="BTC,ETH,SOL,XRP,DOGE,LINK,ADA,LTC") + ap.add_argument("--rows", type=int, default=300_000) + ap.add_argument("--workers", type=int, default=4) + args = ap.parse_args() + + syms = [s.strip() for s in args.symbols.split(",")] + print(f"[1m 过滤器消融] {len(syms)} 币 × {args.rows} 根," + f"出场 SL{SL}/减半{SCALE_AT}/runner{RUNNER}/rstop{RSTOP}/{MAXB}根\n", flush=True) + + parts = [] + with ProcessPoolExecutor(max_workers=args.workers) as ex: + futs = {ex.submit(collect, s, args.rows): s for s in syms} + for i, f in enumerate(as_completed(futs), 1): + r = f.result() + if r is None: + print(f" [{i}/{len(syms)}] {futs[f]} 跳过", flush=True) + continue + parts.append(r) + print(f" [{i}/{len(syms)}] {futs[f]} 信号 {len(r)}", flush=True) + if not parts: + print("无结果") + return + + d = pd.concat(parts, ignore_index=True) + d["a_bp"] = d["atr_pct"] * 1e4 + d.to_feather(HERE / "out" / "step44_ablation_1m.feather") + + for gate_lab, dd in (("未加门控", d), (f"ATR >= {GATE_BP:g}bp", d[d.a_bp >= GATE_BP])): + print("\n" + "=" * 118) + print(f"########## {gate_lab}(共 {len(dd)} 笔)##########") + dark = (dd["htf_agree"] == 1.0) & dd["ladder_ok"] + rows = [ + describe(dd, "全部信号"), + describe(dd[dark], "深色:同向 ∧ 阶梯"), + describe(dd[~dark], "浅色:任一不过"), + describe(dd[(dd["htf_agree"] == 1.0) & ~dd["ladder_ok"]], " 只过同向"), + describe(dd[(dd["htf_agree"] != 1.0) & dd["ladder_ok"]], " 只过阶梯"), + describe(dd[(dd["htf_agree"] != 1.0) & ~dd["ladder_ok"]], " 两个都不过"), + ] + print(pd.DataFrame([r for r in rows if r]).to_string(index=False)) + + print("\n########## 逐币:浅色是正是负(ATR 门控后)##########") + dd = d[d.a_bp >= GATE_BP] + rows = [] + for s, g in dd.groupby("sym"): + dark = (g["htf_agree"] == 1.0) & g["ladder_ok"] + a, b = describe(g[dark], "深"), describe(g[~dark], "浅") + rows.append({"币": s, + "深色笔数": a.get("笔数"), "深色净均R": a.get("净均R"), + "深色余量bp": a.get("滑点余量bp"), + "浅色笔数": b.get("笔数"), "浅色净均R": b.get("净均R"), + "浅色余量bp": b.get("滑点余量bp")}) + print(pd.DataFrame(rows).to_string(index=False)) + + print("\n注:余量为「扣手续费后、可用于吸收滑点的 bp」。" + "低于实测滑点即为亏损,不是「小赚」。") + + +if __name__ == "__main__": + main() diff --git a/research/step45_repaint.py b/research/step45_repaint.py new file mode 100644 index 0000000..7f5aacb --- /dev/null +++ b/research/step45_repaint.py @@ -0,0 +1,262 @@ +"""Step 43:重画率——实时看到过的信号,有多少后来消失了。 + +step39 也报过「假阳性」,但那个测法是无效的:它从全序列随机抽非信号点, +看时点重建会不会凭空冒信号。1m 上信号密度约 474 根 1 个,180 个随机点里 +本来就只期望撞上 0.38 个,测出 0% 几乎不含信息量。 + +重画不发生在随机点上,只发生在「差一点就成型」的结构附近。所以要 +**逐根**做时点重建,把「当根确实出现了信号」的位置全收集起来, +再看它们在全量视角里还在不在。 + + 实时信号 窗口只喂到第 T 根,重建后信号恰好落在第 T 根(实盘会下单的那些) + 重画 该信号在全量重建里不存在(图上后来消失,但实盘已经开了仓) + 漏看 全量有、实时当根没有(step39 已验证 ~0,这里顺带复核) + +只有**深色信号**(h1_agree ∧ ladder_ok)才会真下单,所以分层报告: +浅色重画无所谓,深色重画才影响实盘。 +""" +from __future__ import annotations + +import argparse +import os +import sys +import time +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", 320) + +LTF, HTF = "1m", "5m" +WINDOW = 2000 # step39 证明 1m 在 2000 根就饱和,实盘也用这个 +HTF_WINDOW = 800 # 5m 侧窗口,同 step39 +MAX_ROWS = 1_200_000 + + +def _load(sym: str): + from chanlun import TF_DF + from lib.data import fetch_ohlcv + from lib.fx_signal import extract_fx_signals, signals_to_frame + from lib.nested_bsp import htf_fx_timeline + + pair = f"{sym}/USDT:USDT" + df_l = fetch_ohlcv(pair, LTF, MAX_ROWS) + df_h = fetch_ohlcv(pair, HTF, 10 ** 9) + chan_l = TF_DF(df_l, 1, LTF) + cdf = chan_l.dataframe + chan_h = TF_DF(df_h, 1, HTF) + hdf = chan_h.dataframe + tl = htf_fx_timeline(signals_to_frame(extract_fx_signals(chan_h, hdf)), hdf) + return chan_l, cdf, hdf, tl + + +def _full_signals(chan_l, cdf, tl): + """全量视角的信号集(带两个过滤器)。""" + from chanlun.analysis.fast_bsp import attach_zone_ladder + from lib.fast_bsp3 import find_fast_bsp3 + from lib.nested_bsp import attach_htf_context + from lib.nested_level import build_htf_zones + + zones = build_htf_zones(cdf, LTF, chan=chan_l).reset_index(drop=True) + if zones.empty: + return pd.DataFrame() + sig = find_fast_bsp3(cdf, zones) + if sig.empty: + return pd.DataFrame() + sig = attach_htf_context(sig, cdf, tl, "h1") + sig = attach_zone_ladder(sig, zones) + sig["ts"] = cdf["timestamp"].to_numpy()[sig["entry_idx"].astype(int)] + return sig + + +def scan_chunk(task: tuple) -> dict: + """逐根时点重建,只记录信号恰好落在当根的位置。""" + import warnings as _w + _w.filterwarnings("ignore") + sys.path.insert(0, str(HERE)) + sys.path.insert(0, str(HERE.parent)) + + from chanlun import TF_DF + from chanlun.analysis.fast_bsp import attach_zone_ladder + from lib.fast_bsp3 import find_fast_bsp3 + from lib.fx_signal import extract_fx_signals, signals_to_frame + from lib.nested_bsp import attach_htf_context, htf_fx_timeline + from lib.nested_level import build_htf_zones + + sym, lo, hi = task + try: + chan_l, cdf, hdf, _tl_full = _load(sym) + ltf_ts = cdf["timestamp"].to_numpy() + htf_ts = hdf["timestamp"].to_numpy() + hi = min(hi, len(cdf)) + lo = max(lo, WINDOW) + + hits, t0 = [], time.perf_counter() + for T in range(lo, hi): + sl = cdf.iloc[T - WINDOW + 1: T + 1].reset_index(drop=True) + z = build_htf_zones(sl, LTF) + if z.empty: + continue + z = z.reset_index(drop=True) + sig = find_fast_bsp3(sl, z) + if sig.empty: + continue + last = len(sl) - 1 + row = sig[sig["entry_idx"].astype(int) == last] + if row.empty: + continue + + # 只在真的出信号时才算过滤器——信号稀疏,这部分开销可忽略 + h_end = int(np.searchsorted(htf_ts, ltf_ts[T], side="right")) + agree = np.nan + if h_end >= HTF_WINDOW: + hsl = hdf.iloc[h_end - HTF_WINDOW: h_end].reset_index(drop=True) + ch = TF_DF(hsl, 1, HTF) + tl_p = htf_fx_timeline( + signals_to_frame(extract_fx_signals(ch, ch.dataframe)), ch.dataframe) + got = attach_htf_context(row.copy(), sl, tl_p, "h1") + agree = float(got["h1_agree"].iloc[0] == 1) + lad = attach_zone_ladder(row.copy(), z) + hits.append({ + "sym": sym, "T": int(T), "ts": int(ltf_ts[T]), + "direction": int(row["direction"].iloc[0]), + "h1_agree": agree, + "ladder_ok": bool(lad["ladder_ok"].iloc[0]), + }) + return {"sym": sym, "lo": lo, "hi": hi, "n_bars": hi - lo, + "hits": pd.DataFrame(hits), "secs": time.perf_counter() - t0} + except Exception as e: + return {"sym": sym, "error": repr(e)[:300]} + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--symbols", default="BTC,ETH,SOL,DOGE,XRP,LINK") + ap.add_argument("--bars", type=int, default=30000, help="每币逐根扫描的根数") + ap.add_argument("--workers", type=int, default=10) + ap.add_argument("--chunk", type=int, default=2500) + args = ap.parse_args() + + syms = [s.strip() for s in args.symbols.split(",")] + out_dir = HERE / "out" + out_dir.mkdir(exist_ok=True) + + # 先拿各币全量信号做基准,同时确定扫描区间 + print(f"[重画审计] {len(syms)} 币 × 每币 {args.bars} 根逐根重建," + f"窗口 {WINDOW}\n", flush=True) + full_map, tasks = {}, [] + for s in syms: + try: + chan_l, cdf, hdf, tl = _load(s) + full = _full_signals(chan_l, cdf, tl) + full_map[s] = full + hi = len(cdf) - 1 + lo = max(WINDOW, hi - args.bars) + for a in range(lo, hi, args.chunk): + tasks.append((s, a, min(a + args.chunk, hi))) + print(f" {s}: 全量 {len(cdf)} 根,全量信号 {len(full)}," + f"扫描 [{lo}, {hi})", flush=True) + except Exception as e: + print(f" {s}: 载入失败 {e!r}", flush=True) + + est = sum(t[2] - t[1] for t in tasks) * 0.202 / args.workers + print(f"\n共 {len(tasks)} 个分块,预计 {est / 60:.0f} 分钟\n", flush=True) + + res, done = [], 0 + with ProcessPoolExecutor(max_workers=args.workers) as ex: + futs = {ex.submit(scan_chunk, t): t for t in tasks} + for f in as_completed(futs): + r = f.result() + done += 1 + if "error" in r: + print(f" [{done}/{len(tasks)}] {r['sym']} 出错 {r['error']}", flush=True) + continue + res.append(r) + print(f" [{done}/{len(tasks)}] {r['sym']} [{r['lo']},{r['hi']}) " + f"实时信号 {len(r['hits'])} 个,{r['secs']:.0f}s", flush=True) + + if not res: + print("无结果") + return + + live = pd.concat([r["hits"] for r in res if len(r["hits"])], ignore_index=True) + live.to_feather(out_dir / "step43_live_signals.feather") + n_bars = sum(r["n_bars"] for r in res) + + # 对齐:实时信号的时间戳是否出现在全量信号集里 + rows = [] + for s, g in live.groupby("sym"): + full = full_map.get(s) + fts = set(full["ts"].astype(int).tolist()) if full is not None and len(full) else set() + g = g.copy() + g["survived"] = g["ts"].isin(fts) + rows.append(g) + live = pd.concat(rows, ignore_index=True) + live["dark"] = (live["h1_agree"] == 1.0) & live["ladder_ok"] + + print("\n" + "=" * 100) + print(f"########## 1. 总体(扫描 {n_bars} 根)##########") + n, sv = len(live), int(live["survived"].sum()) + print(f" 实时出现过的信号 {n} 个,全量视角仍在 {sv} 个," + f"重画 {n - sv} 个 = {(n - sv) / max(n, 1) * 100:.2f}%") + + print("\n########## 2. 按过滤器分层(只有深色会真下单)##########") + rows = [] + for lab, m in (("深色(双过滤通过)", live["dark"]), + ("浅色(未通过)", ~live["dark"])): + gg = live[m] + if not len(gg): + continue + k = int((~gg["survived"]).sum()) + # Wilson 95% 上界,样本小的时候点估计没意义 + from math import sqrt + nn, p = len(gg), k / len(gg) + z = 1.96 + hi_b = (p + z * z / (2 * nn) + z * sqrt(p * (1 - p) / nn + z * z / (4 * nn * nn))) / (1 + z * z / nn) + rows.append({"分层": lab, "实时信号": nn, "重画": k, + "重画率": f"{p * 100:.2f}%", "95%上界": f"{hi_b * 100:.2f}%"}) + print(pd.DataFrame(rows).to_string(index=False)) + + print("\n########## 3. 分币种 ##########") + rows = [] + for s, g in live.groupby("sym"): + d = g[g["dark"]] + rows.append({"币": s, "实时信号": len(g), "其中深色": len(d), + "深色重画": int((~d["survived"]).sum()) if len(d) else 0, + "全部重画": int((~g["survived"]).sum())}) + print(pd.DataFrame(rows).to_string(index=False)) + + print("\n########## 4. 漏看(全量有、实时当根没有)复核 ##########") + for s, g in live.groupby("sym"): + full = full_map.get(s) + if full is None or not len(full): + continue + lo = min(r["lo"] for r in res if r["sym"] == s) + hi = max(r["hi"] for r in res if r["sym"] == s) + inrange = full[(full["entry_idx"] >= lo) & (full["entry_idx"] < hi)] + seen = set(g["ts"].astype(int).tolist()) + miss = int((~inrange["ts"].astype(int).isin(seen)).sum()) + print(f" {s}: 扫描区间内全量信号 {len(inrange)},实时当根未出现 {miss} 个" + f"({miss / max(len(inrange), 1) * 100:.1f}%)") + + print("\n########## 结论 ##########") + d = live[live["dark"]] + if len(d): + k = int((~d["survived"]).sum()) + print(f" 深色信号 {len(d)} 个,重画 {k} 个。") + print(" 重画的仓位是真实成交的,但出场(止损/止盈/超时)不依赖信号是否还在图上,") + print(" 所以不会卡仓;影响仅限于「实盘比回测多开的这部分,质量不在回测统计里」。") + + +if __name__ == "__main__": + main() diff --git a/research/step47_avail_bi_ab.py b/research/step47_avail_bi_ab.py new file mode 100644 index 0000000..93f5ce5 --- /dev/null +++ b/research/step47_avail_bi_ab.py @@ -0,0 +1,195 @@ +"""Step 47:中枢可用时刻取 bis[-1] 还是 bis[2] —— 按收益判,不按重画率判。 + +§5.41 发现 available_ts 取「中枢最后一笔」是右边缘重画的根因,改取「第三笔」 +(中枢成立即固定)能把重画率从 6.5% 压到 1.2%。但那只测了稳定性。 + +小样本预检(60k 根 × 5 个币/周期)显示这不是一次「稳定性修补」: + 信号数 +17% ~ +44%,而两组的**重合度只有约 30%**。 + 即 bis[2] 丢掉了原信号的多数,又换进来一批新的。 +换句话说它是另一个策略,不是同一个策略的低延迟版。所以判据必须是扣费后的 +净 R / PF / 滑点预算,重画率只能作为次要参考。 + +口径与 step44 一致(当前 1m 最优): + 出场 SL 2.0 / 3 ATR 减半 / runner 目标 8 ATR / runner 止损留原位 / 48 根超时 + 成本 taker 2bp、maker 0.8bp,滑点只加在 taker 腿 + 门控 ATR >= 8bp + 过滤 深色(同向 ∧ 阶梯)——实盘只做这一档,见 §3.38 + +两组共用同一个 TF_DF,只切 available_ts 的取法,确保差异只来自这一处。 +""" +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) + +LTF, HTF = "1m", "5m" +SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48 +GATE_BP = 8.0 +VARIANTS = (("bis[-1] 现行", -1), ("bis[2] 中枢成立", 2)) + + +def collect(sym: str, rows: int) -> pd.DataFrame | None: + import warnings as _w + _w.filterwarnings("ignore") + sys.path.insert(0, str(HERE)) + sys.path.insert(0, str(HERE.parent)) + + from chanlun import TF_DF + from chanlun.analysis.fast_bsp import ( + add_zone_ladder, attach_htf_agree, attach_zone_ladder, + build_htf_zones, find_fast_bsp3, htf_fx_timeline, + ) + from lib.data import fetch_ohlcv + from lib.exit_model import cfg_name, walk_exits + + try: + df = fetch_ohlcv(f"{sym}/USDT:USDT", LTF, rows) + if df is None or len(df) < 50_000: + return None + # lean:只需要笔/中枢/信号,跳过线段与 MACD 状态机(见 §5.6,已验证等价) + chan = TF_DF(df, 1, LTF, lean=True) + cdf = chan.dataframe + + df_h = fetch_ohlcv(f"{sym}/USDT:USDT", HTF, 10 ** 9) + chan_h = TF_DF(df_h, 1, HTF, lean=True) + tl = htf_fx_timeline(chan_h, chan_h.dataframe) + + idx_all, parts = None, [] + for label, avail_bi in VARIANTS: + zones = build_htf_zones(cdf, LTF, chan=chan, avail_bi=avail_bi) + if zones.empty: + continue + zl = add_zone_ladder(zones.reset_index(drop=True)) + sig = find_fast_bsp3(cdf, zl) + if sig.empty: + continue + sig = attach_zone_ladder(attach_htf_agree(sig, cdf, tl), zl) + + res = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB], + scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,)) + cfg = cfg_name(SL, RUNNER, MAXB, RSTOP) + need = [f"{cfg}_g", f"{cfg}_r", f"{cfg}_c", f"{cfg}_b"] + if any(c not in res.columns for c in need): + continue + + idx = sig["entry_idx"].to_numpy().astype(int) + atr = cdf["atr"].to_numpy(float)[idx] + close = cdf["close"].to_numpy(float)[idx] + out = res[need].copy() + out.columns = ["g", "r", "c", "b"] + out["sym"] = sym + out["variant"] = label + out["atr_pct"] = atr / close + out["lag"] = sig["lag"].to_numpy() + out["entry_idx"] = idx + out["htf_agree"] = sig["htf_agree"].to_numpy() + out["ladder_ok"] = sig["ladder_ok"].to_numpy() + parts.append(out) + return pd.concat(parts, ignore_index=True) if parts else None + except Exception as e: + print(f" {sym} 失败: {e!r}", flush=True) + return None + + +def describe(g: pd.DataFrame, label: str) -> dict: + from lib.exit_model import fee_of, taker_notional + + if len(g) < 40: + return {"口径": label, "笔数": len(g), "备注": "样本不足"} + gross = g["g"].to_numpy() + reason, scaled = g["r"].to_numpy(), g["c"].to_numpy() + net = gross - fee_of(reason, scaled) # 未扣滑点:剩下的就是滑点余量 + denom = SL * g["atr_pct"].to_numpy() + R, gR = net / denom, gross / denom + w, o = net[net > 0], -net[net <= 0].sum() + q = np.quantile(net[net > 0], 0.90) if (net > 0).any() else 0.0 + t10 = net[(net > 0) & (net <= q)].sum() + tn = taker_notional(reason, scaled) + return { + "口径": label, "笔数": len(g), + "滞后": round(float(g["lag"].mean()), 2), + "胜率": f"{(net > 0).mean() * 100:.1f}%", + "毛R": round(gR.mean(), 3), + "净均R": round(R.mean(), 3), + "R夏普": round(R.mean() / R.std(ddof=1), 3), + "PF": round(w.sum() / o, 2) if o > 0 else np.inf, + "剔10%PF": round(t10 / o, 2) if o > 0 else np.inf, + "滑点余量bp": round(net.mean() / tn.mean() * 1e4, 2), + } + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--symbols", default="BTC,ETH,SOL,XRP,DOGE,LINK,ADA,LTC") + ap.add_argument("--rows", type=int, default=300_000) + ap.add_argument("--workers", type=int, default=3) + args = ap.parse_args() + + syms = [s.strip() for s in args.symbols.split(",")] + print(f"[available_ts A/B] {len(syms)} 币 × {args.rows} 根 {LTF}\n" + f"出场 SL{SL}/减半{SCALE_AT}/runner{RUNNER}/rstop{RSTOP}/{MAXB}根," + f"ATR 门控 {GATE_BP:g}bp\n", flush=True) + + parts = [] + with ProcessPoolExecutor(max_workers=args.workers) as ex: + futs = {ex.submit(collect, s, args.rows): s for s in syms} + for i, f in enumerate(as_completed(futs), 1): + r = f.result() + if r is None: + print(f" [{i}/{len(syms)}] {futs[f]} 跳过", flush=True) + continue + parts.append(r) + n = r.groupby("variant").size().to_dict() + print(f" [{i}/{len(syms)}] {futs[f]} {n}", flush=True) + if not parts: + print("无结果") + return + + d = pd.concat(parts, ignore_index=True) + d["a_bp"] = d["atr_pct"] * 1e4 + d.to_feather(HERE / "out" / "step47_avail_bi.feather") + + dark = (d["htf_agree"] == 1.0) & d["ladder_ok"] + for lab, dd in (("全部信号", d), ("深色(实盘口径)", d[dark])): + for gate_lab, ddd in (("未门控", dd), (f"ATR>={GATE_BP:g}bp", dd[dd.a_bp >= GATE_BP])): + print("\n" + "=" * 118) + print(f"########## {lab} / {gate_lab} ##########") + print(pd.DataFrame([describe(ddd[ddd.variant == v], v) + for v, _ in [(a, b) for a, b in VARIANTS]]).to_string(index=False)) + + print("\n########## 逐币(深色 + 门控)##########") + dd = d[dark & (d.a_bp >= GATE_BP)] + rows = [] + for s, g in dd.groupby("sym"): + r = {"币": s} + for v, _ in VARIANTS: + x = describe(g[g.variant == v], v) + tag = "现行" if "-1" in v else "新" + r[f"{tag}_笔数"] = x.get("笔数") + r[f"{tag}_净均R"] = x.get("净均R") + r[f"{tag}_余量bp"] = x.get("滑点余量bp") + rows.append(r) + print(pd.DataFrame(rows).to_string(index=False)) + + print("\n判据:净均R 与滑点余量bp 同时不劣于现行,才值得换。" + "\n信号数变多本身不是好处——重合度只有约 30%,换的是另一批交易。") + + +if __name__ == "__main__": + main()