否掉 bis[2]:修右边缘重画会把 alpha 打成零,重画是必付代价

§5.41 发现 available_ts 取「中枢最后一笔」是右边缘重画的根因,改取「第三笔」
能把重画率从 6.5% 压到 1.2%,当时据此判断它是「唯一可能同时改善收益与稳定性」
的改动。那个判断只测了稳定性,过早了。

8 个样本外币 × 30 万根 1m,两组共用同一个 TF_DF,只切 available_ts 的取法。
实盘口径(深色 ∧ ATR≥8bp,955 vs 989 笔):

  毛 R      0.933 → -0.000
  净均 R    0.798 → -0.147
  PF        3.22  → 0.80
  滑点余量  15.07 → -2.20 bp

判决依据是毛 R 那一行:扣任何费用之前 edge 就没了,所以不是成本、门控或出场
参数的问题,是信号本身不再有预测力。逐币 8/8 全部变差。滞后确实降了
(2.16 → 2.01),但换来的是另一批交易——两组重合度只有约 30%。

原因是中枢没发育完就下注,支撑/压力还没立住。「等中枢最后一笔」那段等待不是
可以优化掉的延迟,它就是 alpha 本身。由此得一条一般规则:任何以「让信号更早
确定」为目标的改动,先测毛 R,不能只看重画率和滞后。

开关 AVAIL_BI_INDEX 保留只为可复现该 A/B,默认 -1 维持现行口径。环境变量在
调用时解析而非 import 时——fork 启动的子进程会继承已 import 的模块,import
时读会固化成父进程的值。

顺带交叉验证:现行口径本次算出滑点余量 15.07bp,与用优化前代码算的同组同期
15.19bp 吻合。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-28 04:05:01 +08:00
co-authored by Cursor
parent 0b4d7693b8
commit d2fcb27d01
5 changed files with 1157 additions and 13 deletions
+30 -7
View File
@@ -44,6 +44,27 @@ import pandas as pd
from chanlun.core.ChanEnum import Chan_FX_TYPE 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: def timestamps_ms(src: pd.DataFrame) -> np.ndarray:
"""取毫秒时间戳。研究侧的 df 自带 timestampweb 侧的不一定,故按 date 回退。 """取毫秒时间戳。研究侧的 df 自带 timestampweb 侧的不一定,故按 date 回退。
@@ -69,7 +90,7 @@ def ensure_timestamp(df: pd.DataFrame) -> pd.DataFrame:
return out 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 笔中枢,返回带生效时间的区间表。 """算 pure 笔中枢,返回带生效时间的区间表。
available_ts —— 该中枢最早可被使用的时间戳(其确认时刻)。 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) zs_list = chan.cal_bi_zs_list_pure(chan.bi_list)
# 用引擎自己的 dataframe 对齐,避免调用方传入的 df 与引擎内部行数不一致 # 用引擎自己的 dataframe 对齐,避免调用方传入的 df 与引擎内部行数不一致
src = chan.dataframe if getattr(chan, "dataframe", None) is not None else df_htf 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 笔中枢转成区间表。 """把已算好的 pure 笔中枢转成区间表。
调用方手工跑过 cal_bi_zs_list_pure 时走这里,免得再算一遍(web 的 analyze_chan 调用方手工跑过 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))) ts_of = dict(zip(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"), timestamps_ms(src)))
# 中枢可用时刻取第几笔。在循环外解析一次,别让每个中枢都去读一遍环境变量。
i = _resolve_avail_bi(avail_bi)
rows = [] rows = []
for zs in zs_list: for zs in zs_list:
bis = getattr(zs, "bi_list", []) bis = getattr(zs, "bi_list", [])
if not bis: if not bis:
continue continue
# 中枢可用时刻:构成它的最后一笔被确认之时 # 笔数不够时退回最后一笔(i=2 需要至少 3 笔才成立)
last_bi = bis[-1] key_bi = bis[i] if (i == -1 or len(bis) > i) else bis[-1]
sure_key = str(getattr(last_bi, "sure_time", "") or "") sure_key = str(getattr(key_bi, "sure_time", "") or "")
end_key = str(getattr(last_bi, "end_time", "") or "") end_key = str(getattr(key_bi, "end_time", "") or "")
avail = ts_of.get(sure_key) or ts_of.get(end_key) avail = ts_of.get(sure_key) or ts_of.get(end_key)
if avail is None: if avail is None:
continue continue
+482 -6
View File
@@ -267,13 +267,39 @@ push = z_above if direction == 1 else z_below
#### 3.33 扩币的筛选标准(两条方向相反的约束) #### 3.33 扩币的筛选标准(两条方向相反的约束)
1. **ATR 分布对门控阈值**(当前 8bp)——小市值币波动大,这条更容易过 1. **ATR 分布对门控阈值**(当前 8bp)——够不够扛住固定成本
2. **流动性 / 盘口价差**——决定滑点能否压进预算,小市值币更难过 2. **流动性 / 盘口价差**——滑点能否压进预算
**两条方向相反,所以最优区间在中间,不在两端。** 现有 11 币正好演示: **两条都是及格线,不是「越高越好」的排序依据。**
BTC ATR 中位 2026 仅 6.5bp、门控刷掉 58.5%、预算 8.58bp 垫底(**大市值输在
波动不够**);TRX 2026 门控后只剩 4.2% 的信号,当前环境基本不能做。 ⚠️ **「那就做 ATR 最大的」是错的**,两个方向都不支持:
最好的是中间那批:ADA 22.28、DOGE 18.55、XRP 17.04。
**币内**(每个币按自己的 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 分位数与盘口价差,**不必跑策略** 筛选很便宜:拉候选币 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.87ADA/AVAX/
DOGE/ETH/LINK/LTC/SOL/XRP)、最差 17.61、中位 18.66,极差 2.2612%)。
⚠️ **这一条已被 §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 HKT5 次快照取中位)。
**净预算 = 回测预算 − $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 前置测量) ### 3.35 跨 venue:信号身份不迁移,但总体期望迁移(live 前置测量)
**回测全部跑在 Binance 数据上,实盘要在 Bitget 成交。** 服务器侧的 **回测全部跑在 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` ⚠️ 上面那批数字用的是 `毛均收益 6.0bp`、只有同向过滤、`TP=3.0`
**不能直接当预算用**,正确的逐币预算见 `lib/shadow_budget.py` 与 5.3。 **不能直接当预算用**,正确的逐币预算见 `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 消融) ### 3.4 alpha 的来源(step32 消融)
逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位, 逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位,
@@ -451,6 +656,8 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算
| 同一中枢重复入场(二次、三次) | 质量骤降,**只做首次** | step25 | | 同一中枢重复入场(二次、三次) | 质量骤降,**只做首次** | 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 | | 缠论引擎原生 `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 | | 刷交易额换 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`)做大级别 | 滞后太大,且中枢极少 | 早期,用户也这么说 | | 线段(`Chan_XD`)做大级别 | 滞后太大,且中枢极少 | 早期,用户也这么说 |
--- ---
@@ -707,6 +914,106 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算
比 15m/30m 的 step38 结果还干净(那里有 0.7% 假阳性)。耗时对窗口严格线性 比 15m/30m 的 step38 结果还干净(那里有 0.7% 假阳性)。耗时对窗口严格线性
(约 0.105ms/根),所以没有任何理由带更长的历史。 (约 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 1m300 时点,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 已跑完(step472026-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 的回测口径本身是可信的, **这一步顺带解决了一个更重要的问题**1m 的回测口径本身是可信的,
`is_sure` 回撤对 1m 同样没有影响。**1m 现在唯一的风险就剩执行成本。** `is_sure` 回撤对 1m 同样没有影响。**1m 现在唯一的风险就剩执行成本。**
@@ -721,10 +1028,164 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算
用 **WebSocket 订阅**而非轮询 REST 拉 K 线,并尽快把实盘迁到境外 VPS。 用 **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 根 > 为什么这一步必要:step38 只验证了 15m/30m,从没验证 1m。而窗口 4000 根
> 对 15m 是 41 天、对 1m 只有 2.8 天,中枢的左边界效应完全不是一个量级。 > 对 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 → 754ms2.6x),lean → 543ms3.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.3sfull 模式 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.2016 万根 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. 接下来要做的事(按优先级) ## 6. 接下来要做的事(按优先级)
@@ -1076,6 +1537,21 @@ API 限流风险隔离三个好处。
- [x] **确认交易所与框架** → Bitget + Hummingbot,先只上 1m - [x] **确认交易所与框架** → Bitget + Hummingbot,先只上 1m
- [x] **测执行延迟** → 上游连接器 bug 已定位并修好,补丁后 lag 506~642ms、 - [x] **测执行延迟** → 上游连接器 bug 已定位并修好,补丁后 lag 506~642ms、
无条件漂移 0.50~0.87bp,占预算 6%。见 §6 第 2 步 无条件漂移 0.50~0.87bp,占预算 6%。见 §6 第 2 步
- [ ] **实盘信号计算改用增量**(§5.5,已验证一致且快 12x286ms → 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.0008/8 币全变差。
**这条已关闭,不要重开。**
- [ ] **查那 9.0% 的「中枢消失」是不是窗口左边界效应**(§5.41)。若是,则
「回测用全量历史建中枢、实盘用 2000 根窗口」是比重画更根本的口径差。
注意这条与 `bis[2]` 无关,**没有被 step47 否掉**,仍然要查
- [ ] **深色信号大样本重画审计**,脚本已写:`research/step45_repaint.py`
(逐根时点重建,替换掉 step39 那个随机抽点的无效测法)。
§5.42 之后这条的意义变了:不再是为了「修掉重画」,而是为了**量化实盘
会多开多少仓、那部分的质量如何**——重画本身已确认是必付代价
- [ ] **写 V2 controller + 盘口缓冲,跑影子测量**(当前第一件事)。 - [ ] **写 V2 controller + 盘口缓冲,跑影子测量**(当前第一件事)。
字段与统计口径见 §6 第 3 步,**三条硬要求别漏**:只统计过滤后的信号根、 字段与统计口径见 §6 第 3 步,**三条硬要求别漏**:只统计过滤后的信号根、
条件漂移与无条件漂移分开报、出场腿按 maker/taker 分开统计 条件漂移与无条件漂移分开报、出场腿按 maker/taker 分开统计
+188
View File
@@ -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()
+262
View File
@@ -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()
+195
View File
@@ -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()