Compare commits
16
Commits
080ff7d20e
...
6b72ba226b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b72ba226b | ||
|
|
f14ef540c1 | ||
|
|
7585491481 | ||
|
|
e21f1103c5 | ||
|
|
84ee59ecfb | ||
|
|
93ed315a4b | ||
|
|
ed741c16b8 | ||
|
|
380d6d61cd | ||
|
|
7292ef5dd4 | ||
|
|
5695d8e983 | ||
|
|
78708ddeee | ||
|
|
e137d92b50 | ||
|
|
9efbae6ade | ||
|
|
be3602f5ba | ||
|
|
409dc2f020 | ||
|
|
30592889aa |
@@ -974,6 +974,708 @@ mom10 同形状(Q2 最好、Q4 最差),样本内外一致。**势要有—
|
||||
> **② 在几十个分组里挑出的最显著那个,默认它是噪声,除非它能在另一个总体上
|
||||
> 复现。** p 值不做多重比较校正就等于没做检验。
|
||||
|
||||
### 3.393 一类/二类买卖点:引擎实现下全是负的,一类尤其糟(step55)
|
||||
|
||||
用户提出把 B4 那套研究思路搬到一二类上,并指定用 5m/15m 测。
|
||||
|
||||
**先量滞后,不先跑收益**——`Chan_BSP_TYPE` 的枚举注释里写着 B4 存在的全部意义
|
||||
就是「几何位置同 B3/S3,但不等笔确认」,说明这个项目早就付过等笔确认的学费。
|
||||
|
||||
**① 滞后:三类完全一样(用户的判断,实测确认)**
|
||||
|
||||
| 类型 | 5m 滞后中位 | 15m 滞后中位 | 5m 漂移中位 | 占 2ATR 止损 |
|
||||
|---|---|---|---|---|
|
||||
| B1 | 8 根 | 9 根 | 1.68 ATR | 84% |
|
||||
| B2 | 8 根 | 8 根 | 2.03 ATR | 101% |
|
||||
| B3 | 8 根 | 9 根 | 1.95 ATR | 98% |
|
||||
|
||||
三类都卡在同一处:`中枢 is_sure` + `笔 is_sure` + `sure_time`。
|
||||
**所以滞后不是区分一二三类的变量**,它是 `find_all_bsp` 这个实现的共同属性。
|
||||
|
||||
⚠️ 「漂移为正的占比 100%」是**定义决定的,不是发现**:笔之所以在那里结束,
|
||||
正是因为价格从极值反向走了,`bi.end_klc` 就是极值点。有信息量的是幅度不是符号。
|
||||
|
||||
**② 收益:全负,一类是最差的一档**(5m / 15m,5 币,出场用实盘的 2/3/8/2/48)
|
||||
|
||||
| 类型 | 5m 笔数 | 5m 胜率 | 5m 毛R | 5m PF | 5m t值 | 15m PF | 15m t值 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **B1** | 623 | **18.6%** | −0.592 | **0.24** | **−17.3** | 0.21 | −18.9 |
|
||||
| **S1** | 644 | **14.8%** | −0.675 | **0.20** | **−22.3** | 0.23 | −15.9 |
|
||||
| B2 | 293 | 35.8% | −0.104 | 0.73 | −1.4 | 0.85 | −0.8 |
|
||||
| S2 | 288 | 36.8% | −0.115 | 0.82 | −1.6 | 0.75 | −2.4 |
|
||||
| B3 | 2806 | 33.3% | −0.187 | 0.64 | −8.0 | 0.76 | −5.3 |
|
||||
| S3 | 2685 | 35.4% | −0.135 | 0.70 | −5.6 | 0.73 | −4.8 |
|
||||
|
||||
**口径校验通过**:B3 跑出 PF 0.64/0.76,§4 记的引擎 B3 是 0.66 —— 接线是对的,
|
||||
所以 B1/B2 的数可以信。
|
||||
|
||||
**③ 一类为什么烂得这么彻底:它的结构自带反向杠杆**
|
||||
|
||||
B1 买点的理想入场是那一笔的低点。等到 `sure_time` 可以动手时,价格已经**朝上
|
||||
跑了 1.68 ATR**。于是新入场价往下 2 ATR 的止损,落在**比原始低点还低 0.3 ATR**
|
||||
的位置——几乎贴着极值,任何一次回踩都打掉;而 3 ATR 的减半目标从低点算起变成
|
||||
了 4.7 ATR。胜率 15~21% 完美对应这个几何:**它低于随机入场**(SL2/TP3 下随机
|
||||
约 40%)。
|
||||
|
||||
**这和三类的滞后代价不是一回事。** 三类是顺势突破,等 8 根是「追高一点」;
|
||||
一类是逆势抄底,等 8 根是「在反弹了 1.7 ATR 之后才去接刀」——**同样的滞后,
|
||||
在反转型信号上是加倍惩罚**。
|
||||
|
||||
**④ 结论与下一步**
|
||||
|
||||
引擎现成的 B1/B2 **不可用**,且原因是结构性的不是参数性的,调参救不回来。
|
||||
B3 当年的解法不是调参,是**重新定义成实时判据**(B4:收盘突破 zg → 收盘未跌回
|
||||
→ 重新上行,全部当根可判),选样口径随之改变(627 个中枢只认 212 个),
|
||||
统计性质才翻正。
|
||||
|
||||
**但这条路未必能照搬到一类**:B4 能实时判,是因为「突破 + 回踩不破 + 重新上行」
|
||||
每一步都能用收盘价当根判定。而一类的核心是**背驰**,它要求「知道这一笔已经
|
||||
结束」——那本身就是事后信息。要做实时版,得先找到一个不依赖笔端点的力竭代理。
|
||||
|
||||
> 另有一个来自 §3.391 的先验值得先算:**一类按定义就发生在一段已走完的行情
|
||||
> 末端,也就是 `mom60` 最高的那个区间——而那正是我们实测最差的四分位**
|
||||
> (Q4 发现期余量只剩 1.44bp)。真要做实时一类,先验就不利。
|
||||
|
||||
### 3.394 实时版一类(fast B1/S1):滞后不是病根,一类没有边(step56)
|
||||
|
||||
§3.393 判定引擎 B1/S1 不可用后,用户要求照 B3→B4 的路子做实时版。做了,
|
||||
**没救回来**,但这次实验把病因锁死了。
|
||||
|
||||
`research/lib/fast_bsp1.py` 把引擎 B1 的三个条件全换成当根可判的代理:
|
||||
|
||||
| 引擎条件 | 滞后源 | 实时代理 |
|
||||
|---|---|---|
|
||||
| 中枢已成 `zs.is_sure` | 无 | `available_ts`(与 B4 同口径) |
|
||||
| 一笔向下离开中枢 | 笔端点 | **收盘** < zd |
|
||||
| 背驰 `check_bi_div` | 笔端点 | 进入段面积在中枢确认时已是历史;离开段面积从突破根逐根累加 |
|
||||
| 触发 `leave_bi.sure_time` | **主要滞后源** | 收盘反向越过前一根极值(沿用 B4 的转强判据) |
|
||||
|
||||
注意一类和三类**方向相反**:中枢向下突破后 B4 做空,fast B1 是在同一段下跌里
|
||||
找力竭然后做多。
|
||||
|
||||
**结果(5 币,5m/15m,出场 2/3/8/2/48)**
|
||||
|
||||
| 信号 | 5m 笔数 | 5m 胜率 | 5m PF | 5m t值 | 15m PF | 15m t值 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| fastB1 一买 | 482 | 22.4% | 0.38 | −9.4 | 0.37 | −10.6 |
|
||||
| fastB1 一卖 | 528 | 20.3% | 0.36 | −12.5 | 0.41 | −9.6 |
|
||||
| **fastB3 三买**(同管线对照) | 972 | **55.0%** | **1.83** | **+10.6** | 1.84 | +8.5 |
|
||||
| **fastB3 三卖**(同管线对照) | 927 | **54.6%** | **1.93** | **+10.5** | 2.06 | +10.7 |
|
||||
|
||||
**对照组是这次实验的全部价值所在**:同一份数据、同一套出场、同一条管线,
|
||||
B4 跑出 PF 1.83~2.06 / t +8.5~+10.7。所以 fast B1 的负数不是管线接错了。
|
||||
|
||||
**病根锁定:滞后不是主因。** 引擎版 PF 0.22 → 实时版 0.38,确实有改善,
|
||||
但离 1.0 还差得远。更关键的是滞后分档:
|
||||
|
||||
| lag(入场根 − 极值根) | 5m 笔数 | 5m PF |
|
||||
|---|---|---|
|
||||
| ≤1 根(≈买在极值上) | 472 | 0.40 |
|
||||
| 2-3 根 | 390 | 0.36 |
|
||||
| 4-6 根 | 125 | 0.29 |
|
||||
|
||||
**即使入场就在极值那一根,PF 仍只有 0.40。** §3.393 里「等 8 根导致几何劣势」
|
||||
的解释因此只对了一半:滞后确实在扣分,但**把滞后清零也救不活**——一类在这个
|
||||
市场/周期/出场结构下本身就是负边缘的。
|
||||
|
||||
背驰强度有单调性但不够:15m 上最强四分位 PF 0.99、余量 −0.24bp、t −2.0,
|
||||
毛收益刚够打平、净收益仍为负;且这是四选一的样本内挑选,不构成可交易的结论。
|
||||
|
||||
**判定:一类到此为止,不再投入。** 这与 §3.4 消融、§3.391 的 `mom60` 结论
|
||||
互相印证——这一族信号的 alpha 在**顺势延续**上,不在反转上。fast_bsp 的文档
|
||||
头早就写了「B4 不只是 B3 提前几根,两者统计性质符号相反」,一类的实验说明
|
||||
反过来也成立:**把反转信号提前,它还是反转信号。**
|
||||
|
||||
### 3.395 ⛔ 一类反手:**已被 step59 推翻,不要用本节的 PF**(step55/57)
|
||||
|
||||
> **本节结论作废。** 下面的 PF 2.3~3.0 用 `sure_time+1` 当入场时点,而 §3.396
|
||||
> 的逐根回放证明实盘要等中位 **+15 根**才知道信号存在(0% 能准时拿到),
|
||||
> 按真实首现根入场后 PF 塌到 0.92 / 1.30、t 0.20 / 0.64。
|
||||
> 保留本节是为了记录推理链和那个「胜率低于随机 = 信号指反了」的判据,
|
||||
> 它本身是对的、也可复用;错的是把 `sure_time` 当成了可执行时刻。
|
||||
|
||||
|
||||
|
||||
用户问「是不是一类二类的识别错了」。查下来结论是:**代码没写错,但信号该反着用。**
|
||||
|
||||
**先排除掉一个我自己的错误猜测。** `check_bi_div` 只比 MACD 面积、不检查创新极值,
|
||||
这是缠论背驰定义(创新极值 **且** 力度减弱)丢了一半。看着像致命缺陷,但实测
|
||||
**93.2% 的 B1 / 93.4% 的 S1 离开笔本来就创了新极值**,缺口只影响约 7%,
|
||||
解释不了 PF 0.2。**先量再断,别拿理论缺陷当结论。**
|
||||
|
||||
**真正的线索是胜率的方向**:B1 胜率 18.6%,而 SL2/TP3 下随机入场约 40%。
|
||||
**远低于随机说明样本带信息,只是指反了**——真正没用的信号会落在 40% 附近。
|
||||
于是拿相反方向重跑出场(路径依赖,不能把 g 取负号了事):
|
||||
|
||||
| 反手 | 5m 胜率 | 5m PF | 5m t值 | 15m PF | 15m t值 |
|
||||
|---|---|---|---|---|---|
|
||||
| **B1(反手做空)** | 64.4% | **2.35** | +10.4 | **3.03** | +12.6 |
|
||||
| **S1(反手做多)** | 67.1% | **2.75** | +12.8 | 2.63 | +9.6 |
|
||||
| B3/S3(反手) | 45~49% | 1.14~1.24 | +4 | 1.15~1.20 | +4 |
|
||||
|
||||
**稳健性(step57)——这是本项目做过的最稳的结果**
|
||||
|
||||
| 切法 | 结论 |
|
||||
|---|---|
|
||||
| 逐币 | 5 个币全正。15m PF 2.25~4.12,5m 2.31~2.70 |
|
||||
| 前后半段 | 15m 2.81 / 2.81,5m 2.55 / 2.51,几乎不变 |
|
||||
| 逐年 | 2021~2026 **连续六年全正**,15m PF 2.57~2.99 |
|
||||
| 多空 | 两边都正,排除单边行情 |
|
||||
| 余量 | 15m 33~76bp、5m 18~28bp。对比 B4 只有约 3bp |
|
||||
|
||||
**为什么不是 bug 而要反着用**
|
||||
|
||||
代码是照缠论写的:向下离开中枢 + 背驰 → B1 买。**教科书没错,错的是它发信号的
|
||||
时刻。** 信号在 `leave_bi.sure_time` 发出,中位滞后 8~9 根,此时价格已从低点
|
||||
**反弹了 1.68 ATR**(§3.393 表)。引擎想说「抄底」,但它给出的时间戳对应的是
|
||||
「反弹已走完」。在下跌趋势里于此处做空 —— 那是标准的**顺势回调入场**。
|
||||
|
||||
所以这一族信号的几何识别是对的,方向标注按理论也是对的,**是滞后把它变成了
|
||||
相反的交易**。它是一个穿着反转外衣的顺势回调信号。
|
||||
|
||||
**它与 B4 不是一回事**:B4 是突破延续(§3.391 实测 mom10 中位 +3.47 ATR,
|
||||
根本不是回调入场),这个是回调延续。两种入场原型不同,**可能互补**。
|
||||
|
||||
**止损位置:真问题,但不是主因(step58)**
|
||||
|
||||
用户提出:B1 做多的止损可能挂在了 B2 低点上方,于是在那次「回踩不破前低」的
|
||||
正常回抽处被打掉,而不是等趋势真正走完。查下来**观察成立且比预估更严重**:
|
||||
|
||||
| 量 | 5m 一类 |
|
||||
|---|---|
|
||||
| 入场价到结构极值(ATR,用入场根 ATR 归一) | 中位 **2.90** |
|
||||
| 2ATR 止损在结构位外侧留的余地 | 中位 **−0.90**(在结构**以内**) |
|
||||
| **止损落在结构位以内的占比** | **88.6%** |
|
||||
|
||||
即近九成的一类做多,价格连回踩到前低都不需要就已出局。
|
||||
|
||||
修掉之后确实有改善,但救不活。⚠️ 做这个对照时目标必须**随止损等比放大**
|
||||
(恒定 1.5R 减半 / 4R 收尾);放宽止损却不放大目标会把盈亏比压到 1 以下,
|
||||
测出来的「放宽无效」是自证的:
|
||||
|
||||
| 方向 | 止损 | 中位宽度 | 胜率 | PF | t值 |
|
||||
|---|---|---|---|---|---|
|
||||
| 原方向做多 | 固定 2ATR | 2.00 | 16.7% | 0.22 | −31.0 |
|
||||
| 原方向做多 | 结构+1.5 | 4.40 | 33.1% | **0.36** | −16.6 |
|
||||
| 反手做空 | 固定 2ATR | 2.00 | 65.7% | **2.53** | +14.4 |
|
||||
| 反手做空 | 结构+1.5 | 4.40 | 58.2% | 2.15 | +10.7 |
|
||||
|
||||
**结论:趋势确实越过第二类买卖点继续走原方向。** 最有说服力的读法是
|
||||
「结构+1.5」那一档——止损宽达 4.4 ATR,仍有 **67% 被打掉**,即三分之二的一类
|
||||
信号会在 48 根内跌破结构低点再多走 1.5 ATR。缠论里「B2 回踩不破前低」这条
|
||||
在本市场多数时候不成立。
|
||||
|
||||
佐证:反手做空在**最窄的 2ATR 止损上最好**(2.53),放宽反而降到 2.15。
|
||||
这与顺势回调入场自洽——价格若重新涨过反弹高点,论点当场失效,不该留余地。
|
||||
|
||||
**仍未验证,落地前必须做完(否则不许上实盘)**
|
||||
|
||||
1. **因果性**。`zs.is_sure` / `bi.sure_time` 在全量数据上算出,§5.41 已知中枢
|
||||
右边缘会重画。必须用增量/流式回放确认信号在当时真的发得出来。
|
||||
*反证据(不能替代验证)*:同一套机器算出的 B3 是 PF 0.64 的烂数,
|
||||
若存在通用的未来函数,B3 不该这么难看。
|
||||
2. **消融:是不是「反弹就做空」都赚**。照 §3.4 对 B4 做过的那样,把背驰条件
|
||||
拿掉、把中枢换成伪阻力位,看 PF 掉不掉。掉不下来就说明 alpha 在几何和
|
||||
时点上,与背驰无关,那这个信号就该重新命名和重新设计。
|
||||
3. **与 B4 的重叠度与相关性**。若只是换名字的 B4,就没有增量价值。
|
||||
|
||||
### 3.396 ⭐ 因果回放:信号存在性是真的,时点是假的(step59)
|
||||
|
||||
§3.395 的 PF 太好,先查因果性——增量模块文件头自己写着「最后一笔 is_sure
|
||||
**允许收回**」,§5.41 也记了中枢右边缘会重画。
|
||||
|
||||
**做法**:`init_stream` 预热 2 万根,随后逐根 `append_bar`,每根之后重算
|
||||
`cal_bi_zs_list_pure` + `find_all_bsp`,记录每个信号**第一次出现**在哪一根;
|
||||
入场用首现根,而不是事后的 `sure_time`。信号身份用「类型 + 极值 KLC 时刻」,
|
||||
**不能用 `sure_time` 当键**——它正是会被重画的字段。
|
||||
|
||||
必须逐根。分段重建(每 S 根算一次)等于在第 t 根多给了 S 根的信息,
|
||||
测出来的因果性是假的。
|
||||
|
||||
| 检查 | 结果 |
|
||||
|---|---|
|
||||
| 召回(全量信号在回放中出现过的比例) | **100%**(B1 50/50,S1 65/65) |
|
||||
| 幻影(回放发过、全量没有的) | **0** |
|
||||
| 首现根 − 全量 `sure_time` | 中位 **+15 根**,P25 +11,P75 +22,P90 +31 |
|
||||
| **准时拿到的比例** | **0%** |
|
||||
|
||||
| 反手 · 5m | 全量口径 | **回放首现口径** |
|
||||
|---|---|---|
|
||||
| B1 | PF 2.35 | **0.92**(t 0.20,余量 −1.37bp) |
|
||||
| S1 | PF 2.75 | **1.30**(t 0.64,余量 +4.21bp) |
|
||||
|
||||
**结论**:信号的**存在性**是因果的(不重画、不消失),但**时点**不是。
|
||||
实盘要晚中位 15 根、5m 上 75 分钟才知道它存在,而那笔交易吃的正是
|
||||
「反弹已走完」这个窗口,晚 75 分钟就没了。t 值 0.20/0.64 完全不显著。
|
||||
|
||||
⚠️ **可复用的教训**:`sure_time` 是引擎事后标注的确认时刻,**不等于可执行时刻**。
|
||||
任何用它当 entry 的回测都要先过逐根回放。样本 115 笔偏薄,但机制清楚、
|
||||
点估计从 2.35 塌到 0.92,方向不存在疑义。
|
||||
|
||||
### 3.397 线段顶点当标准答案:识别是对的,但精度和时点都不够(step60)
|
||||
|
||||
用户提出用线段终点当趋势反转的标准答案——是未来函数,但用作**标签**验证
|
||||
检测器而非用于交易,能把「检测器对不对」和「能不能交易」分开。
|
||||
|
||||
**对照组是关键**:B1 按构造就长在笔的低点上,而笔低点本来就有概率撞上线段底。
|
||||
所以问的是「在所有同向笔端点里,B1 这个标签把命中率提高了多少倍」。
|
||||
|
||||
| 5m | 样本 | 命中线段顶点 | 相对笔端点的提升 |
|
||||
|---|---|---|---|
|
||||
| 笔端点(基准) | 48229 | 15.0% | 1.00 |
|
||||
| **B1** | 623 | **30.3%** | **2.02×** |
|
||||
| **S1** | 644 | 27.2% | 1.81× |
|
||||
| B3 / S3 | 2806 | **0.0%** | 0.00 |
|
||||
|
||||
15m 同向:B1 28.1%(1.81×)、S1 27.3%(1.76×)。
|
||||
|
||||
各容差档(±0~±5 根)数字完全相同,不是 bug:线段终点**本身就是某根笔的终点**,
|
||||
两者天然按笔端点对齐,放宽容差不会多命中。B3/S3 恰好 0% 也是对的——
|
||||
三类长在回抽笔的端点上,按定义就在趋势中段,不该命中反转点。这两条互为
|
||||
标签有效性的旁证。
|
||||
|
||||
**所以检测器是有信息量的:它把「这个笔低点是真反转」的概率翻了一倍。**
|
||||
但还有两道坎:
|
||||
|
||||
| 5m · 按原方向抄底 | 笔数 | 胜率 | PF | t值 |
|
||||
|---|---|---|---|---|
|
||||
| **未命中**线段顶点 | 903 | 8.7% | **0.08** | −42.0 |
|
||||
| **命中**线段顶点 | 364 | 36.3% | **0.70** | −3.2 |
|
||||
|
||||
15m:未命中 PF 0.08、命中 PF 0.83(t −0.36)。
|
||||
|
||||
**精度只有 30%,而那 70% 的噪声是灾难性的(PF 0.08)。更要命的是:即使用
|
||||
未来函数把精度提到 100%,也只到 PF 0.70~0.83,仍不赚钱。**
|
||||
|
||||
原因是 §3.398 的几何:入场价已在结构底上方 **2.90 ATR**(step58)。
|
||||
即使你真站在线段底上,从底部上方 2.9 ATR 处用 2 ATR 止损做多,结构本身就是
|
||||
负期望的。§3.396 的回放还要在此之上再晚 15 根。
|
||||
|
||||
**一类的完整诊断(三层,逐层否定)**
|
||||
|
||||
1. 识别对不对 —— **对**,2× 提升(step60)
|
||||
2. 精度够不够 —— **不够**,30%;且用未来函数选到 100% 也只到 PF 0.83
|
||||
3. 时点来不来得及 —— **来不及**,全量口径已晚到入场价高出结构底 2.9 ATR,
|
||||
实盘回放再晚 15 根
|
||||
|
||||
**判定:一类线到此为止。** 病不在识别,在于「等笔确认」这个机制天然把信号
|
||||
推到了结构失效之后,而这是 `find_all_bsp` 的固有属性,不是可调的参数。
|
||||
|
||||
### 3.398 极值点的波动率:观察成立,但解释力有限(step61)
|
||||
|
||||
用户提出:一二类长在极值点上,那个区域波动天然大,这可能就是确认慢的原因。
|
||||
拆成三条可证伪的子命题分别测,结论是**一条成立、一条只沾边、一条不成立**。
|
||||
|
||||
**① 一类确实长在高波动区 —— 成立,且很干净**
|
||||
|
||||
`atr_z` = 该点 ATR / 其**之前** 200 根的中位 ATR(含当根会把要检验的插针算进去)。
|
||||
|
||||
| 5m | 样本 | atr_z 中位 | 高于基准占比 |
|
||||
|---|---|---|---|
|
||||
| 笔端点(基准) | 48219 | 1.003 | **50%** |
|
||||
| **B1** | 623 | **1.224** | **74%** |
|
||||
| **S1** | 644 | 1.173 | 68% |
|
||||
| B3 / S3 | 2806 | 1.03~1.06 | 55~56% |
|
||||
| **B2 / S2** | 293 | **0.94~0.97** | **39~44%** |
|
||||
|
||||
基准恰好落在 50%,说明构造是对的。一类比随机笔端点显著偏高。
|
||||
|
||||
⚠️ **但二类是反过来的**:B2/S2 的 atr_z 中位 < 1,只有约四成高于基准 ——
|
||||
**二类长在低波动区**。这符合结构:二类是首轮反转冲动之后的回抽,那时波动已
|
||||
平复。所以「一二类都在极值点」这个说法对一类成立、对二类不成立,
|
||||
两者是镜像而非同类。
|
||||
|
||||
**② 波动大 → 确认慢 —— 只沾边,不是主因**
|
||||
|
||||
| 5m · atr_z 四分位 | atr_z 中位 | 滞后中位 | 滞后均值 |
|
||||
|---|---|---|---|
|
||||
| Q1 低 | 0.85 | 8.0 | 9.3 |
|
||||
| Q2 | 1.07 | **7.0** | 8.8 |
|
||||
| Q3 | 1.34 | 8.0 | 9.6 |
|
||||
| Q4 高 | 1.80 | 9.0 | 11.3 |
|
||||
|
||||
`corr(atr_z, lag_bars) = +0.10`(15m 亦为 +0.107)。方向对,但只解释约 1% 的
|
||||
方差,滞后中位几乎不随波动变化。**滞后是结构性的**——笔要等分型形成再确认,
|
||||
这个 8~9 根跟波动率基本无关(§3.393 已测得三类买卖点滞后完全一致)。
|
||||
|
||||
**③ 入场后波动率回落 → 目标够不着 —— 回落属实,但不是亏损的机制**
|
||||
|
||||
`atr_fwd` = 入场后 48 根平均 ATR / 入场时 ATR:一类 5m 中位 **0.896**(69% 回落),
|
||||
三类 0.945~0.957。即一类的 3/8 ATR 目标按一个虚高的 ATR 定,绝对价格上约高估
|
||||
10%。回落是真的。
|
||||
|
||||
**但它不是死因**:
|
||||
|
||||
| 5m | 走到 3ATR 减仓 | 止损 | 超时 | 止盈 |
|
||||
|---|---|---|---|---|
|
||||
| **一类** | **9.4%** | **81.3%** | 17.9% | 0.8% |
|
||||
| 三类 | 30.7% | — | — | — |
|
||||
|
||||
**九成的一类根本走不到第一个目标,八成直接止损出场。** 若死因是「目标够不着」,
|
||||
超时占比该很高;实际超时只有 17.9%。所以一类不是「走不动」,是**入场后立刻
|
||||
朝反方向走**。
|
||||
|
||||
按 atr_z 分档看表现也印证:PF 在 0.17~0.25 之间基本持平,高波动组并不更差
|
||||
也不更好 —— 波动率**不是**区分变量。
|
||||
|
||||
**小结**:极值点高波动这个观察是对的,但它既没造成滞后(结构性的),也没造成
|
||||
亏损(死于立即反向而非目标够不着)。它与 §3.397 的结论一致:一类的入场价已在
|
||||
结构底上方 2.9 ATR,那个位置本身就是负期望的。
|
||||
|
||||
### 3.399 ⛔ 一类线收尾:过滤器符号随入场时点翻转,判定为拟合(step62/56)
|
||||
|
||||
用户指出:很多一二类实际在**趋势中途**被识别而非末期;若真在末期,即使有延迟
|
||||
也该走出行情。step60 的数据支持这个判断——命中线段顶点的 30% 是 PF 0.70~0.83,
|
||||
**没命中的 70% 是 PF 0.08**,亏损几乎全部来自误识别在趋势中途的那批。
|
||||
|
||||
**step62:找到了区分变量,识别精度接近翻倍**
|
||||
|
||||
`ext_run`(极值越过中枢边界几个 ATR)单调有效,5m:
|
||||
|
||||
| ext_run 档 | 命中线段顶点 | PF |
|
||||
|---|---|---|
|
||||
| Q1 最短 | **12.0%** | 0.12 |
|
||||
| Q4 最延伸 | **43.8%** | 0.46 |
|
||||
|
||||
最佳组合 `ext_run≥P75 且 div≥中位`:命中率 47.9%、PF 0.54
|
||||
(基准 28.7% / 0.22)。**识别确实可以优化,且幅度不小。**
|
||||
|
||||
三个附带结论:
|
||||
|
||||
- **背驰越强反而越差**:div Q1(最强)命中 17.0% / PF 0.13,Q4(最弱)
|
||||
37.5% / 0.28。这是对 `check_bi_div` 这个 MACD 面积判据的直接证伪。
|
||||
- **趋势级数无区分力**:连续同向中枢数 ≤1/2/3/≥4 的命中率
|
||||
28.5/30.6/28.6/25.5%,基本持平。
|
||||
- **「趋势 vs 盘整」前提引擎已隐含强制**(纠正我先前的猜测):B1 要求
|
||||
`enter_bi.dir == leave_bi.dir == DOWN`,中枢向下进、向下出本身就定义了它嵌在
|
||||
下跌趋势里,实测该条件在 2504 笔上**恒为 True**,过滤器无从添加。
|
||||
`zs_count` 也不可用——它是全局中枢序号(310、585)而非趋势内序号。
|
||||
|
||||
**step56 复测:把过滤器接到低滞后版上,符号翻转**
|
||||
|
||||
step56 的实时版把滞后压到 ≤1 根但无质量过滤(PF 0.40),step62 有过滤但滞后
|
||||
未解(PF 0.54)。两者各解决一半,合起来是唯一同时处理两个约束的路径。结果:
|
||||
|
||||
| 5m · 延伸度分档 | 滞后版(step62) | 低滞后版(step56) |
|
||||
|---|---|---|
|
||||
| Q1 最短 | 0.12 | **0.58** |
|
||||
| Q4 最延伸 | **0.46** | 0.33 |
|
||||
|
||||
叠加后 PF 从 0.37 掉到 0.30~0.33,**比不过滤更差**。`div` 同样翻转:滞后版里
|
||||
背驰最弱的最好,低滞后版里背驰最强的最好。
|
||||
|
||||
**这个符号翻转是判定依据,不是需要再调的参数。** 同一批信号、同一个特征,
|
||||
换个入场时点最优方向就反过来,说明这些「过滤效果」是入场时点的交互产物,
|
||||
不是信号的稳定属性。继续在上面挑阈值就是拟合噪声。
|
||||
|
||||
**一类线全部尝试汇总(六次独立进攻,全部止步于 1.0 以下)**
|
||||
|
||||
| 尝试 | 最好 PF | 出处 |
|
||||
|---|---|---|
|
||||
| 引擎原生 | 0.20~0.24 | step55 |
|
||||
| 反手 | 2.35~3.03 → **0.92/1.30** 因果回放后 | step57 / **step59** |
|
||||
| 实时重写(fast_bsp1) | 0.36~0.41 | step56 |
|
||||
| 结构止损(目标等比放大) | 0.36 | step58 |
|
||||
| 线段顶点选样(**用未来函数**) | 0.70~0.83 | step60 |
|
||||
| 质量过滤(ext_run + div) | 0.54 | step62 |
|
||||
| **低滞后 + 质量过滤** | **0.33** | step56 |
|
||||
|
||||
注意第五行:**即使用未来函数把选样做到完美,也只到 0.83。**
|
||||
|
||||
**判定:一类线关闭。** 病根是 §3.397 的几何——入场价已在结构底上方 2.9 ATR,
|
||||
实盘回放还要再晚 15 根(§3.396),而这来自「等笔确认」这个机制本身,
|
||||
不是可调参数。识别可以优化,但识别从来不是瓶颈。
|
||||
|
||||
### 3.3991 ⛔ 二类:不是继承了一类的错,是信息为零(step64)
|
||||
|
||||
用户提的悖论:二类按定义是「一买后回调、再继续趋势」,一类既已证否,二类凭什么好?
|
||||
|
||||
代码上悖论成立——`find_all_bsp` 里 B2 被 B1 门控(`if first_bsp_bi_div`)。但当时
|
||||
有个反驳值得测:B2 比 B1 多要求价格**确实反弹了**、且回踩**守住了** B1 低点,
|
||||
这两条是「那个底是真的」的事后确认,而 §3.397 的诊断恰恰是一类缺这个确认。
|
||||
若成立,B2 就是 B1 里被验证过的子集,是好事而非坏事。
|
||||
|
||||
**测下来是坏消息,且机制与用户说的不同。**
|
||||
|
||||
⚠️ 先记一个**我用错的口径**:报表里 B2/S2「命中线段顶点 0.0%」(276~293 笔一个
|
||||
没中)**是定义决定的,不是发现**。B2 按定义是反弹后的更高低点,线段顶点在 B1
|
||||
那个极值上,两者不可能重合。这个指标对自称在极值的一类有效,对二类无效。
|
||||
若日后要给二类找标准答案,该问的是「B2 之后 B1 低点是否再未被破」,不是顶点重合。
|
||||
|
||||
两组有效结论:
|
||||
|
||||
**一、二类的几何是所有信号里最差的**
|
||||
|
||||
| | 入场到结构止损位 | 2ATR 止损落在结构内侧 |
|
||||
|---|---|---|
|
||||
| B1 | 2.90 ATR | 88.6% |
|
||||
| **B2** | **4.87 ATR** | **99~100%** |
|
||||
|
||||
二类的天然止损位是 B1 的低点(回踩守住的就是那点)。但反弹 + 回踩之后,入场价
|
||||
离那个低点已有 4.87 ATR。**二类没有可用的止损位**:挂 2 ATR 是纯任意止损,
|
||||
挂到结构位则单笔风险近 5 ATR。step58 在一类上试过放宽到结构位,PF 仅 0.22→0.36;
|
||||
二类的缺口比一类大 68%,没有理由更好。
|
||||
|
||||
**二、二类两个方向都没有边缘——这是它和一类的本质区别**
|
||||
|
||||
| 5m/15m | 原方向 PF / t | 反手 PF / t |
|
||||
|---|---|---|
|
||||
| B1 | 0.21~0.24 / **−17 ~ −19** | 2.35~3.03 / **+10 ~ +13** |
|
||||
| S1 | 0.20~0.23 / **−16 ~ −22** | 2.63~2.75 / **+9.6 ~ +12.8** |
|
||||
| B2 | 0.73~0.85 / −0.79 ~ −1.42 | 1.09~1.10 / +1.67 ~ +1.70 |
|
||||
| S2 | 0.75~0.82 / −1.55 ~ −2.40 | 0.85~1.13 / +0.19 ~ +1.36 |
|
||||
|
||||
**一类 t = −17~−22 是强烈指反**:它有信息,只是滞后把「反转信号」变成了
|
||||
「反弹追单」(反手曾看似 PF 3.0,但 §3.396 已证那是 `sure_time` 后视产物)。
|
||||
**二类两边 t 都贴着零**,原方向 −0.8、反手 +1.7,5m 反手 S2 甚至 0.85。
|
||||
没有可提取的东西,连反手都没有。
|
||||
|
||||
其余画像与 §3.393 一致:二类 atr_z 中位 0.94~0.97(低波动,与一类的 1.22 相反),
|
||||
滞后 8 根(与一类持平,§3.393 已记「三类滞后无差别」)。
|
||||
|
||||
**判定:二类关闭,一二类线整体收尾。** 一类是**信号指反**(有信息、方向错、
|
||||
错在滞后),二类是**信号消失**——在一类之上又等两笔,把仅剩的方向性磨掉,
|
||||
同时把止损位推远到 4.87 ATR。用户的悖论方向对,但不是「继承错误」,
|
||||
是「多等的那两笔把信息也等没了」。
|
||||
|
||||
### 3.3992 ⭐ 一类线重开:病根是选样×止损的交互,不是没机会(step65/58/66)
|
||||
|
||||
用户问「那如果修改 B1/B2 的识别规则呢」。这个问题**推翻了 §3.399 的关闭结论**,
|
||||
下面三步是重开的依据。
|
||||
|
||||
**step65:先量机会本身装不装得下确认成本(不经过检测器,直接取 `seg_list`)**
|
||||
|
||||
| 级别 | 线段中位幅度 | 扣 2.9 ATR 确认成本后 | 剩余 > 2ATR 止损 | 剩余/止损 |
|
||||
|---|---|---|---|---|
|
||||
| 5m | 11.4 ATR | 8.5 | **93%** | **4.26x** |
|
||||
| 15m | 10.9 | 8.0 | 93% | 3.99 |
|
||||
| 1h | 11.5 | 8.6 | 95% | 4.30 |
|
||||
| 4h | 12.4 | 9.5 | 96% | 4.74 |
|
||||
|
||||
五币四级别一致(10.5~13.7)。**滞后不是瓶颈**——这与我原先的预期相反。
|
||||
但它与 §3.397 直接矛盾(空间在,却拿不到),矛盾的解答就是下面这条。
|
||||
|
||||
**step58 复测:先排除一个伪解释。** 首轮结构止损用了 `r_scale=True`,
|
||||
3.9 ATR 的止损把 runner 目标推到 15.6 ATR,比 11.4 ATR 的整段行情还长。
|
||||
改成绝对 3/8 ATR 目标重跑,PF 仍是 0.36(与 r_scale 版**完全相同**)。
|
||||
**我预判的这个修正不成立**,首轮结论正确只是理由错了。
|
||||
|
||||
**step66:把两个杠杆同时拧(三者首次组合),天花板一次性打开**
|
||||
|
||||
| 5m | 固定 2ATR | 结构+1.0 |
|
||||
|---|---|---|
|
||||
| 落在真线段端点(28.7%) | PF **0.70** / 胜率 36% | PF **2.29** / 胜率 63% |
|
||||
| 没落在(71.3%) | 0.08 | 0.14 |
|
||||
|
||||
15m:真底那批 **0.83 → 4.11**,胜率 44% → 70%。
|
||||
|
||||
**这是交互效应,不是叠加**——前三次进攻各自只动了一半,所以全部失败:
|
||||
|
||||
| 只做完美选样(step60) | 只放宽止损(step58) | **两个一起(step66)** |
|
||||
|---|---|---|
|
||||
| 0.70 / 0.83 | 0.36 | **2.29 / 4.11** |
|
||||
|
||||
MFE/MAE 给出机制:真底那批**逆向行程中位 2.24 ATR(5m)/ 2.08(15m)**,
|
||||
即 2 ATR 止损**打掉了 51~58% 的好单**;而非真底那批逆向行程中位 4.76/4.93,
|
||||
放宽只是亏更多。**固定 2 ATR 同时做错两件事:杀掉对的单,又留不住错的单。**
|
||||
|
||||
⚠️ **这个 2.29/4.11 里有两个未来函数,不是策略,是上界**:
|
||||
(a) 选样用了线段端点;(b) 入场仍在 `sure_time`,而 §3.396 证明实盘还要再晚 15 根。
|
||||
它证明的是**失败原因可修(选样精度 + 止损位),而非这个级别没得做**。
|
||||
|
||||
**由此产生的两个待跑实验:**
|
||||
1. ✅ **实时过滤器 + 结构止损** → 见 §3.3993,兑现不了,但换来一个确切门槛
|
||||
2. **超时从 48 根放宽**(仍未跑)。线段中位时长 **113 根**,48 根超时在行情走完
|
||||
42% 时就平仓;真底那批 MFE 中位仅 2.37 ATR,可能是被截断的(而非行情本身小)
|
||||
|
||||
### 3.3993 ⭐ 一类线的门槛量化:全局只有一个变量,精度要 67~73%(step67)
|
||||
|
||||
把 §3.3992 的天花板换成**实时可算**的 `ext_run` 选样,配结构止损:
|
||||
|
||||
| | 5m | 15m |
|
||||
|---|---|---|
|
||||
| 全体无过滤 | 0.34 | 0.32 |
|
||||
| ext_run ≥ P75(实时) | 0.51 | 0.48 |
|
||||
| ext_run≥P75 且 div≥中位 | 0.60 | 0.46 |
|
||||
| ★ 真线段端点(未来函数) | **2.29** | **4.11** |
|
||||
|
||||
**兑现不了。** 但数字对不上——Q4 的真端点浓度已达 44%(基线 28.7%),
|
||||
若真端点值 2.29,44% 浓度不该只有 0.51。拆开看:
|
||||
|
||||
| ext_run 档 | 真端点组 PF (5m / 15m) | 非真端点组 PF (5m / 15m) |
|
||||
|---|---|---|
|
||||
| Q1 最短 | 2.53 / 2.61 | 0.18 / 0.11 |
|
||||
| Q4 最延伸 | 2.22 / **6.10** | 0.16 / 0.12 |
|
||||
|
||||
**这张表是本节的结论**:`ext_run` 在真端点**内部**毫无反向选样(5m 持平、
|
||||
15m 单调递增到 6.10),在非真端点内部也毫无区分力(恒在 0.11~0.18)。
|
||||
**全局只有「是不是真端点」这一个变量在起作用**,其余特征都是它的噪声代理。
|
||||
|
||||
这同时**修正了 §3.399 的判定**:当时把 `ext_run` 的符号翻转判为拟合。
|
||||
现在看它不是拟合——它确实提纯(28.7% → 44%),只是提纯幅度远远不够。
|
||||
|
||||
于是 PF 退化成两组按精度 p 的混合,可解出门槛:
|
||||
|
||||
| 实时精度 | 28.7%(当前) | 44%(ext_run) | 60% | 70% | 100%(上界) |
|
||||
|---|---|---|---|---|---|
|
||||
| PF (5m) | 0.34 | 0.50 | 0.73 | 0.93 | 2.29 |
|
||||
| PF (15m) | 0.33 | 0.51 | 0.81 | 1.10 | 4.11 |
|
||||
|
||||
**盈亏平衡精度:5m 需 72.6%,15m 需 67.1%。** 当前 28.7%,`ext_run` 做到 44%。
|
||||
|
||||
**判定:一类线的问题已完全定性,但门槛极高。** 要把「这是不是真线段底」的实时
|
||||
判准精度做到 ~70%(现在 28.7%,最好的特征到 44%),本质上是要求实时抄底摸顶的
|
||||
准确率达到七成。这是可以尝试的 ML 问题(标签、特征、评估口径本节都已给全),
|
||||
但**先验上很难**,且 70% 只是毛平衡,还要再扣 §3.396 的 15 根因果滞后。
|
||||
|
||||
> **这解释了为什么 B4 能做而一类不能**:B4 是突破后的**延续**信号,
|
||||
> 不需要判断反转;一类的全部难度集中在「这是不是那个底」这一个二分类上。
|
||||
|
||||
### 3.3994 🚨 B4 因果回放:时点干净,但实盘做的不是回测那批单(step63/68)
|
||||
|
||||
**这条影响正在跑的实盘,优先级高于本文档其余全部内容。**
|
||||
|
||||
**好消息:B4 的时点完全干净,一类那个坑它没有**
|
||||
|
||||
| | B1(§3.396) | **B4** |
|
||||
|---|---|---|
|
||||
| 召回 | 100% | 100% |
|
||||
| **准时率(首现==entry_idx)** | **0%** | **100%** |
|
||||
| 迟到中位 | +15 根 | **0 根** |
|
||||
|
||||
**坏消息:实时回放会多产出一批全量口径里不存在的信号,且它们显著亏钱**
|
||||
|
||||
按**实盘口径**(2001 根滚动窗口、每 500 根 `init_stream` 重建、只做当根收盘,
|
||||
逐行对齐 `shadow_signal.py`)重放 5 币 × 20000 根:
|
||||
|
||||
| 过滤 | 留下 | **额外占比** | 全部 PF | 额外 PF | 回测口径 PF |
|
||||
|---|---|---|---|---|---|
|
||||
| 无过滤 | 789 | 75% | 0.64 | 0.46 | 1.37 |
|
||||
| 仅同向 | 402 | 77% | 0.65 | 0.35 | 2.65 |
|
||||
| 仅阶梯 | 341 | 68% | 0.71 | 0.31 | 2.14 |
|
||||
| 仅 ATR 门控 | 721 | 76% | 0.64 | 0.46 | 1.35 |
|
||||
| **三道全开** | **195** | **74%** | **0.73** | **0.26** | **4.21** |
|
||||
|
||||
**三道滤网挡不住这批信号。** 它们把成交量砍掉 75%(789 → 195),但额外信号占比
|
||||
从 75% 只降到 74%——按同样比例刷掉好的和坏的。实盘口径拆开:
|
||||
|
||||
| 分组 | 笔数 | 胜率 | PF | 余量bp | t值 |
|
||||
|---|---|---|---|---|---|
|
||||
| 回测里也有的 | 51 | 74.5% | **4.21** | +30.15 | +5.90 |
|
||||
| **回测里没有的** | **144** | 22.9% | **0.26** | −14.25 | **−6.21** |
|
||||
| **实盘实际会做的** | 195 | 36.4% | **0.73** | −4.65 | −0.59 |
|
||||
|
||||
**结论:回测报的 4.21 是拿不到的。** 那 51 笔只有事后用全量历史重算才能识别;
|
||||
实盘在当下拿到 195 个信号,**没有任何办法分辨哪 51 个是真的**。机制是重画——
|
||||
实时算出的中枢,数据变多后被修正掉,信号随之消失。
|
||||
|
||||
这与 §3.396 是同一类错误的两种表现:一类错在**时点**(信号存在但晚 15 根),
|
||||
B4 错在**存在性**(信号当根就有,但其中 74% 事后会消失)。
|
||||
|
||||
**顺带闭掉一条挂了很久的待办:窗口长度不是问题。** 2001 根滚动窗口与
|
||||
2 万→4 万根增长窗口跑出**完全相同**的 789/197/592,中枢是局部结构,截断无影响。
|
||||
§5.41 那条「9.0% 中枢消失是不是窗口左边界效应」可以判为**否**。
|
||||
|
||||
**1m 复验(实盘真正跑的级别):结论一致**
|
||||
|
||||
用户指出「1m 的参数和其他级别不一样」,核查后:出场参数 `SL2/3ATR减半/留损2/
|
||||
目标8ATR/48根` 两边同形状(§5.3),本轮用对了;但 **`ATR ≥ 8bp` 门控是照 1m
|
||||
标定的**,文档明确它在 5m 上惰性(触发 2.6%,「无害也无用」)。
|
||||
所以 5m 那轮的「三道全开」实际只有两道在起作用,1m 才是三道齐全。
|
||||
|
||||
| | 5m/30m | **1m** |
|
||||
|---|---|---|
|
||||
| 无过滤 | 789 笔 / 75% 额外 / PF 0.64 | 712 笔 / 77% 额外 / PF 0.74 |
|
||||
| **三道全开** | 195 / 74% / **0.73** | **122 / 72% / 0.72** |
|
||||
| 其中回测也有的 | 51 笔 PF 4.21 | 34 笔 PF **2.07** |
|
||||
| 其中额外的 | 144 笔 PF 0.26 | 88 笔 PF **0.36**(t −2.73) |
|
||||
|
||||
**ATR 门控在 1m 上确实咬得很凶(712 → 389,刷掉 45%,5m 只刷 8.6%),
|
||||
但对额外信号毫无区分力**——刷完额外占比仍是 77%,与刷之前相同。
|
||||
三道合起来砍掉 83% 的成交量,额外占比只从 77% 降到 72%。
|
||||
|
||||
⚠️ **三点限定**:
|
||||
- 1m 的回测口径只剩 **34 笔**(t 3.26),比 5m 那轮(51 笔,t 5.90)弱。
|
||||
方向一致但精度有限,**若要据此改实盘,应先扩币或延长样本**
|
||||
- 同向过滤器被**开了未来函数的后门**(大级别分型时间线用全量历史算),
|
||||
真实盘的 HTF 同样会重画,只会**更差**不会更好
|
||||
- 「额外信号」= 实时算得出、全量重算后消失的。这不等于它们在实盘一定被下单,
|
||||
还要看执行层是否有额外去重/冷却,**这一层未审计**
|
||||
|
||||
**这不否定 B4 的 alpha**——回测口径那 51 笔 PF 4.21、t 5.90 是真实的,说明
|
||||
「真中枢上的 B4」确实赚钱。问题是实时分不出真假中枢。
|
||||
|
||||
#### 3.3994a 机制订正:不是重画,是中枢终点在实时不可知
|
||||
|
||||
我最初把它归因为「中枢重画」,**这个归因是错的**,用户两次指出后逐条查证:
|
||||
|
||||
**① 中枢边界不重画(用户对)。** `zg/zd` 由前三笔定死,`verify_window_sens`
|
||||
与 step39 都验过。
|
||||
|
||||
**② 中枢只由已确认的笔构成(结构性保证)。** `cal_bi_zs_list_pure`:
|
||||
|
||||
```python
|
||||
if not (bi1.is_sure and bi2.is_sure and bi3.is_sure): continue
|
||||
if not (leave_bi.is_sure and back_bi.is_sure): break # 延伸时同样要求
|
||||
```
|
||||
|
||||
step70 实测:789 个信号**全部** `z_sure=True`。所以用户说的「浅色 B4 会出现
|
||||
然后消失」在研究路径上不存在(那来自 web 展示用的非 `_pure` 中枢)。
|
||||
⛔ **「补一道 is_sure 门」是空操作**,实测 PF 0.73 → 0.73 分毫未动,不要再提。
|
||||
|
||||
**③ 真正的机制是 `available_ts` 棘轮,§5.41 早已写明:**
|
||||
|
||||
> 6.5% 的改动**不是已确认的笔被推翻**,而是中枢又吸收了新笔、`bis[-1]` 变成了
|
||||
> 另一根笔。**原来那根笔本身没变。**
|
||||
|
||||
`available_ts` = 中枢**最后一笔**的 `sure_time`(`AVAIL_BI_INDEX = -1`),
|
||||
而中枢会持续吸收新确认的笔 → 「最后一笔」换人 → `available_ts` 往后棘轮 →
|
||||
`find_fast_bsp3` 的 200 根扫描窗口整体右移。消失的不是笔,是**中枢的终点**。
|
||||
|
||||
顺带澄清一个我一度担心的伪问题:**中枢跨度 > 200 根不会导致突破落不进窗口**,
|
||||
因为扫描起点是中枢**结束**(末笔确认)而非起点,此时价格已在离开中枢。
|
||||
|
||||
#### 3.3994b ⚠️ 订正:「额外 74%」是按条数统计的放大,按中枢是 52%(step71)
|
||||
|
||||
用户指出「原来的计算是没有问题的,是你算错了」。逐笔对账(不做统计,把两边的
|
||||
中间量全摆出来),结论一半一半。
|
||||
|
||||
**引擎没问题,我的口径也没错位**(BTC 5m,逐笔追 36 个额外信号):
|
||||
|
||||
| 归类 | 个数 | 占比 |
|
||||
|---|---|---|
|
||||
| A 棘轮(全量扫描区间不覆盖入场根) | 36 | **100%** |
|
||||
| B 中枢在全量里不存在 | 0 | 0% |
|
||||
| **C 我的 bug(区间覆盖了却没出信号)** | **0** | **0%** |
|
||||
|
||||
索引也是干净的:原始 df 5001 行 = `TF_DF.dataframe` 5001 行,逐行时间戳全部对齐。
|
||||
额外信号的中枢在全量里**全都存在且 `(zg,zd)` 完全一致**——中枢确实不重画。
|
||||
|
||||
棘轮幅度实测 32~234 根,例如入场根 2141:回放 avail 在 2104 根,
|
||||
全量 avail 已棘轮到 2299 根,全量扫描区间 [2299,2499) 根本不覆盖 2141。
|
||||
|
||||
**但同一张表暴露了我确实算错的地方:**
|
||||
|
||||
| 分组 | 信号条数 | 不同中枢数 | 每中枢触发 | 最多 |
|
||||
|---|---|---|---|---|
|
||||
| 额外 | 36 | **17** | **2.12** | 5 |
|
||||
| 全量也有 | 16 | 16 | **1.00** | 1 |
|
||||
|
||||
全量每个中枢恰好产出一个信号,回放里同一中枢平均触发 2.12 次(最多 5 次)——
|
||||
`max_per_zone=1` 只保证「每次扫描返回一个」,但扫描起点随棘轮后移,越过旧入场点
|
||||
后同一中枢重新产出「第一个」。
|
||||
|
||||
⛔ **所以 §3.3994 的「额外信号占 74%」不成立**,那是按**信号条数**统计的,
|
||||
被重复触发放大约一倍。**按中枢算是 17/33 ≈ 52%。**
|
||||
|
||||
这也把数量级对上了:§5.41 的 6.5% 是**全部中枢**里确认时刻被改的比例,而会产出
|
||||
信号的中枢里被改过的占比自然高得多——两个数不矛盾,**是分母不同,先前拿它们
|
||||
直接对比也是错的**。
|
||||
|
||||
**仍然成立**:棘轮机制、额外信号确实存在且确实亏钱(PF 0.26~0.36)。
|
||||
**已被推翻**:「实盘会多开 74% 的仓」。真实倍数取决于执行层对同一中枢是否
|
||||
去重/冷却——**这一层从未审计,它决定 2.12 这个倍数会不会落到实盘上**。
|
||||
|
||||
### 3.4 alpha 的来源(step32 消融)
|
||||
|
||||
逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位,
|
||||
@@ -2220,6 +2922,47 @@ API 限流风险隔离三个好处。
|
||||
|
||||
## 10. 待办清单
|
||||
|
||||
- [ ] 🚨 **B4 实盘口径 PF 是 0.73,回测报 4.21 —— 最高优先级**(§3.3994)。
|
||||
时点干净(100% 准时,一类那个坑没有),但实时会多产出信号,
|
||||
它们 PF 0.26 / t −6.21,**三道滤网按同比例刷除、完全挡不住**。
|
||||
⚠️ **多出的量按中枢算是 52%,不是先前写的 74%**(§3.3994b 已订正,
|
||||
74% 是按信号条数、被同一中枢重复触发放大了 2.12 倍)。**要做三件事**:
|
||||
- [ ] **⓪ 先审计执行层的去重/冷却 —— 这条现在排在最前**(§3.3994b)。
|
||||
同一中枢在实时会触发 2.12 次(最多 5 次),若执行层已按中枢或按持仓去重,
|
||||
实盘的实际多开量远小于回放,**整件事的严重程度会大幅下降**。
|
||||
在这条查清前,不要据 PF 0.73 去动实盘参数
|
||||
- [x] **① 1m 复验完成**:122 笔 / 72% 额外 / PF 0.72,与 5m 一致。
|
||||
ATR 门控在 1m 上刷掉 45%(5m 只 8.6%)但对额外信号无区分力。
|
||||
**但回测口径只剩 34 笔(t 3.26),据此改实盘前应先扩币或延长样本**
|
||||
- [ ] **①b 扩样本再确认**(改实盘的前置条件)。当前 5 币 × 2 万根,
|
||||
1m 的关键分组只有 34 笔。扩到 8~11 币或 5 万根再看是否稳定
|
||||
- [ ] **①c 审计执行层是否有去重/冷却**。「额外信号」是实时算得出、事后消失的,
|
||||
但实盘是否真的每个都下单,取决于执行层,这一层还没看过
|
||||
- [x] ⛔ **「中枢构成笔 is_sure」已证是空操作**(step70,§3.3994a)。
|
||||
`cal_bi_zs_list_pure` 本就强制全部构成笔 is_sure,789 个信号全是已确认,
|
||||
加这道门 PF 0.73 → 0.73。**不要再提这条**
|
||||
- [ ] **② 先测数量级放大:那 592 个额外信号落在几个不同中枢上**(§3.3994a)。
|
||||
中枢层面只改 6.5%,信号层面却 75%,怀疑 `max_per_zone=1` + 起点棘轮
|
||||
让同一中枢反复产出「第一个」入场点。脚本已写好 `step69_mechanism.py`,
|
||||
**三次后台运行都被中断,需在前台重跑**。这是修法的前置——
|
||||
若确是重复触发,修法是「每中枢只认一次」,代价远小于动 available_ts
|
||||
- [ ] **③ 若非重复触发,再试「等 N 根看信号是否仍在」**(用滞后换稳定性)
|
||||
- [ ] **③ 通知服务器侧**:当前实盘在做一批回测里不存在的单。在 ① 出结果前
|
||||
不建议改参数,但应知晓风险
|
||||
- [x] **窗口左边界效应判为否**(§3.3994)。2001 根滚动窗口与 2万→4万根增长窗口
|
||||
跑出完全相同的 789/197/592,中枢是局部结构,截断无影响
|
||||
- [ ] **一类线:问题已完全定性,等一个决策**(§3.3992 / §3.3993)。
|
||||
全局只有一个变量——**「是不是真线段端点」**:是则 PF 2.29(5m)/4.11(15m),
|
||||
否则恒为 0.13,其余特征都只是它的噪声代理。**盈亏平衡精度 5m 72.6%、
|
||||
15m 67.1%**,当前 28.7%,最好的实时特征 `ext_run` 到 44%。
|
||||
要继续就是做一个「实时判真底」的分类器打到 ~70%,标签/特征/评估口径
|
||||
§3.3993 已给全,但先验很难,且 70% 只是毛平衡,还要再扣 15 根因果滞后。
|
||||
**这条不阻塞任何实盘工作**
|
||||
- [ ] **一类超时从 48 根放宽**(§3.3992,仍未跑,代价很低)。线段中位时长 113 根,
|
||||
48 根在行情走完 42% 时平仓,真底那批 MFE 中位仅 2.37 ATR 疑似被截断
|
||||
- [x] **二类关闭**(step64,§3.3991)。与一类不同,二类是**信号消失**而非信号指反:
|
||||
两个方向 t 都贴零,且入场离结构止损位 4.87 ATR(一类 2.90),
|
||||
§3.3992 的结构止损解法对它不适用
|
||||
- [x] **step39**:1m 时点重建 + 窗口扫描 → **2000 根窗口饱和,计算 0.20s,
|
||||
假阳性 0%,1m 回测口径可信**
|
||||
- [ ] **对齐 step23 与 step42 的 1m 数字**(3.91bp vs 11.06bp)。差距可能来自
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""快速一类买卖点(fast B1/S1)——把引擎的 B1/S1 改写成当根可判的形式。
|
||||
|
||||
**动机**:step55 实测引擎 `find_all_bsp` 的 B1/S1 在 5m/15m 上是重亏的
|
||||
(PF 0.20~0.24、胜率 15~21%、t −17~−22),而且原因是几何性的:引擎在
|
||||
`leave_bi.sure_time` 才发信号,中位滞后 8~9 根,此时价格已朝反弹方向跑了
|
||||
1.68 ATR。逆势信号上,这个滞后是加倍惩罚——新入场价往下 2 ATR 的止损落在
|
||||
比原始低点还低 0.3 ATR 处,几乎贴着极值。
|
||||
|
||||
B3 当年也是同样的病(PF 0.66),解法不是调参而是**重新定义成实时判据**
|
||||
(B4,见 `chanlun/analysis/fast_bsp.py`)。本模块对一类做同样的事。
|
||||
|
||||
引擎 B1 是三个条件的合取(`bsp.py: find_all_bsp` + `check_bi_div`):
|
||||
|
||||
中枢已成 -> 一笔向下离开中枢 -> 该笔 MACD 面积 < 进入笔面积(底背驰)
|
||||
|
||||
三条都有当根可判的代理,滞后来源只有一处——`leave_bi.is_sure`:
|
||||
|
||||
中枢已成 zs.is_sure -> zones.available_ts(与 B4 同口径,已解决)
|
||||
向下离开 leave_bi 端点在 zd 下 -> **收盘** < zd,当根可判
|
||||
背驰 整笔面积对比 -> 进入笔面积在中枢确认时已是历史,
|
||||
离开段面积从突破根起逐根累加,当根可判
|
||||
触发 等笔确认(滞后之源) -> 收盘反向越过前一根极值(沿用 B4 的转强判据)
|
||||
|
||||
**与三类的方向关系相反**:三类顺着突破方向做,一类逆着做。中枢向下突破后,
|
||||
B4 会做空,而 fast B1 是在同一段下跌里找力竭然后**做多**。
|
||||
|
||||
⚠️ 先验不利,落地前请先看数:§3.391 实测 `mom60` 最高的四分位是最差的
|
||||
(余量只剩 1.44bp),而一类按定义就住在一段已走完的行情末端。本模块是用来
|
||||
证伪这个先验的,不是用来假定它不成立的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from chanlun.analysis.fast_bsp import _resolve_avail_bi, timestamps_ms
|
||||
|
||||
|
||||
def zones_with_enter_area(zs_list, src: pd.DataFrame,
|
||||
avail_bi: int | None = None) -> pd.DataFrame:
|
||||
"""区间表 + 进入笔的 MACD 面积与方向。
|
||||
|
||||
这两列**不引入滞后**:进入笔是中枢第一笔的前一笔,中枢确认时它早已收尾。
|
||||
背驰判据里唯一需要等待的是离开段,而那一段可以逐根累加。
|
||||
|
||||
面积口径必须和 `ChanBI.cal_macd_hist` 一致:只累加顺方向那一侧的 hist
|
||||
并取绝对值(上升笔累加正值,下降笔累加负值的绝对值),故恒非负。
|
||||
"""
|
||||
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
|
||||
key_bi = bis[i] if (i == -1 or len(bis) > i) else bis[-1]
|
||||
avail = (ts_of.get(str(getattr(key_bi, "sure_time", "") or ""))
|
||||
or ts_of.get(str(getattr(key_bi, "end_time", "") or "")))
|
||||
if avail is None:
|
||||
continue
|
||||
enter_bi = getattr(bis[0], "pre", None)
|
||||
if enter_bi is None:
|
||||
continue
|
||||
# dir 用 +1/−1 表示,避免把引擎枚举漏进研究侧
|
||||
e_dir = 1 if str(enter_bi.dir).endswith("UP") else -1
|
||||
rows.append({
|
||||
"zg": float(zs.zg), "zd": float(zs.zd),
|
||||
"start_ts": ts_of.get(str(bis[0].start_time), avail),
|
||||
"available_ts": int(avail),
|
||||
"enter_area": abs(float(enter_bi.macd_hist)),
|
||||
"enter_dir": e_dir,
|
||||
})
|
||||
out = pd.DataFrame(rows)
|
||||
if out.empty:
|
||||
return out
|
||||
return out.sort_values("available_ts").reset_index(drop=True)
|
||||
|
||||
|
||||
def find_fast_bsp1(
|
||||
df: pd.DataFrame,
|
||||
zones: pd.DataFrame,
|
||||
scan: int = 200,
|
||||
leave_win: int = 60,
|
||||
min_leave_bars: int = 3,
|
||||
max_div: float = 1.0,
|
||||
max_per_zone: int = 1,
|
||||
diag: dict | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""扫描每个中枢,找「离开中枢后力度背驰、随即转向」的入场点。
|
||||
|
||||
zones 需含 zg / zd / available_ts / enter_area / enter_dir,
|
||||
即 `zones_with_enter_area` 的输出。
|
||||
|
||||
`min_leave_bars` 要求离开段至少走这么多根才允许触发。没有它的话,转强判据
|
||||
会在突破后第一根小反弹就打进去,那时离开段的面积还没累起来,背驰条件几乎
|
||||
自动成立——会退化成「随便一个反弹就抄底」。
|
||||
|
||||
`max_div` 是背驰的松紧:离开段面积 / 进入段面积 < max_div 才算背驰。
|
||||
1.0 等于引擎的 `macdhist_div < 0`;调小则只认力度衰减更明显的。
|
||||
|
||||
返回列:
|
||||
entry_idx 实时可下单的K线
|
||||
direction +1 一买 / −1 一卖(**与突破方向相反**)
|
||||
bo_idx 离开中枢的突破根
|
||||
ext_idx 离开段的极值根
|
||||
lag entry_idx − ext_idx,即相对理想入场点的滞后
|
||||
div 离开段面积 / 进入段面积,越小背驰越强
|
||||
depth 离开段极值越过中枢边界的幅度(相对边界)
|
||||
"""
|
||||
need = {"zg", "zd", "available_ts", "enter_area", "enter_dir"}
|
||||
if zones.empty or not need <= set(zones.columns):
|
||||
return pd.DataFrame()
|
||||
|
||||
ts = df["timestamp"].to_numpy()
|
||||
close = df["close"].to_numpy(float)
|
||||
high = df["high"].to_numpy(float)
|
||||
low = df["low"].to_numpy(float)
|
||||
hist = df["macdhist"].to_numpy(float)
|
||||
atr = df["atr"].to_numpy(float)
|
||||
n = len(df)
|
||||
rows = []
|
||||
|
||||
def note(key: str) -> None:
|
||||
if diag is not None:
|
||||
diag[key] = diag.get(key, 0) + 1
|
||||
|
||||
for zone_i, (_, z) in enumerate(zones.iterrows()):
|
||||
note("中枢总数")
|
||||
zg, zd = float(z["zg"]), float(z["zd"])
|
||||
e_area, e_dir = float(z["enter_area"]), int(z["enter_dir"])
|
||||
if zg <= zd or not np.isfinite(e_area) or e_area <= 0:
|
||||
note("×无效中枢或进入段无面积")
|
||||
continue
|
||||
start = int(np.searchsorted(ts, z["available_ts"], side="left"))
|
||||
if start >= n - 2:
|
||||
note("×中枢太靠后")
|
||||
continue
|
||||
scan_end = min(start + scan * max_per_zone, n)
|
||||
cursor = start
|
||||
|
||||
for occ in range(1, max_per_zone + 1):
|
||||
if cursor >= n - 2:
|
||||
break
|
||||
was_inside = False
|
||||
bo_idx, d = None, 0
|
||||
for j in range(cursor, scan_end):
|
||||
c = close[j]
|
||||
if zd <= c <= zg:
|
||||
was_inside = True
|
||||
continue
|
||||
if not was_inside:
|
||||
continue
|
||||
bo_idx, d = j, (1 if c > zg else -1)
|
||||
break
|
||||
if bo_idx is None:
|
||||
if occ == 1:
|
||||
note("×窗口内未离开中枢")
|
||||
break
|
||||
|
||||
# 引擎要求 enter_bi.dir == leave_bi.dir 才比面积,否则背驰无从谈起
|
||||
if e_dir != d:
|
||||
note("×进入段与离开段不同向")
|
||||
cursor = bo_idx + 1
|
||||
continue
|
||||
|
||||
area = 0.0
|
||||
ext = low[bo_idx] if d == -1 else high[bo_idx]
|
||||
ext_idx = bo_idx
|
||||
entry_idx, div_at = None, np.nan
|
||||
for j in range(bo_idx, min(bo_idx + leave_win + 1, n)):
|
||||
h = hist[j]
|
||||
# 与 ChanBI.cal_macd_hist 同口径:只取顺方向一侧,取绝对值
|
||||
if d == -1 and h < 0:
|
||||
area -= h
|
||||
elif d == 1 and h > 0:
|
||||
area += h
|
||||
if (low[j] < ext) if d == -1 else (high[j] > ext):
|
||||
ext, ext_idx = (low[j] if d == -1 else high[j]), j
|
||||
if j < bo_idx + min_leave_bars:
|
||||
continue
|
||||
# 收盘回到中枢内 -> 这不是一次有效的离开,弃掉
|
||||
if zd <= close[j] <= zg:
|
||||
note("×离开段收盘回到中枢内")
|
||||
break
|
||||
if area >= e_area * max_div:
|
||||
continue # 力度未衰减,不是背驰
|
||||
go = close[j] > high[j - 1] if d == -1 else close[j] < low[j - 1]
|
||||
if go:
|
||||
entry_idx, div_at = j, area / e_area
|
||||
break
|
||||
if entry_idx is None:
|
||||
if occ == 1:
|
||||
note("×未在窗口内背驰转向")
|
||||
cursor = bo_idx + 1
|
||||
continue
|
||||
if occ == 1:
|
||||
note("√成交")
|
||||
|
||||
edge = zd if d == -1 else zg
|
||||
# 延伸度用 ATR 归一,才和 step62 的 ext_run 同口径 —— 那里实测它是
|
||||
# 唯一单调区分「趋势末端 vs 趋势中途」的特征(命中率 12%→44%)。
|
||||
# 用相对边界的百分比会混入价格水平,不同币之间不可比。
|
||||
a_ext = atr[ext_idx]
|
||||
rows.append({
|
||||
"entry_idx": entry_idx, "direction": -d,
|
||||
"bo_idx": bo_idx, "ext_idx": ext_idx,
|
||||
"lag": entry_idx - ext_idx,
|
||||
"div": div_at,
|
||||
"ext_atr": (abs(ext - edge) / a_ext
|
||||
if np.isfinite(a_ext) and a_ext > 0 else np.nan),
|
||||
"depth": abs(ext - edge) / edge,
|
||||
"zg": zg, "zd": zd,
|
||||
"width_pct": (zg - zd) / close[bo_idx],
|
||||
"occ": occ, "zone_i": zone_i,
|
||||
})
|
||||
cursor = entry_idx + 1
|
||||
|
||||
out = pd.DataFrame(rows)
|
||||
if out.empty:
|
||||
return out
|
||||
return (out.sort_values(["entry_idx", "occ", "zone_i"])
|
||||
.drop_duplicates("entry_idx", keep="first")
|
||||
.reset_index(drop=True))
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Step 55:一/二类买卖点的可行性探针——先量滞后,不急着跑收益。
|
||||
|
||||
用户提出把 B4 那套研究思路搬到一二类上。搬之前必须先过一道闸,理由写在
|
||||
`Chan_BSP_TYPE` 的枚举注释里:**B4 存在的全部意义就是"几何位置同 B3/S3,
|
||||
但不等笔确认"**。也就是说这个项目早就付过"等笔确认"的学费。
|
||||
|
||||
而一类买卖点是最滞后的一种构造:它要中枢 `is_sure`、要离开笔 `is_sure`、
|
||||
还要背驰判定(`check_bi_div` 读 `macd_hist`)。二类更靠后,要在一类之后再走
|
||||
两笔。所以真正的问题不是"一二类赚不赚钱",而是:
|
||||
|
||||
你能在什么时候知道它,那时候价格还在不在。
|
||||
|
||||
`ChanBSP` 给了两个时刻,差值就是答案:
|
||||
klc.end_time = leave_bi.end_klc 的收盘时刻,**极值所在**,理想入场点
|
||||
sure_time = 笔被确认的时刻,**你最早能动手的时刻**
|
||||
|
||||
本步只回答三件事,跑得快、结论硬:
|
||||
1. 一二类各有多少笔(对比 B4 的 5.3 笔/天 / 10 币)
|
||||
2. 滞后多少根
|
||||
3. 这段等待里价格跑掉多少个 ATR —— 这是"入场价漂移",直接从余量里扣
|
||||
|
||||
⚠️ 不在本步做收益回测。滞后若不可接受,收益怎么算都是假的:`walk_exits`
|
||||
用的是信号根的次根开盘价,而那个价在一二类上根本拿不到。
|
||||
"""
|
||||
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)
|
||||
|
||||
OUT = HERE / "out" / "step55_bsp12.feather"
|
||||
# B4 的对照基线,来自 §3.31 / step48:10 币 1m 实盘口径
|
||||
B4_PER_DAY_10SYM = 5.3
|
||||
# 出场结构与实盘完全一致,见 live/exit_params.py
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
|
||||
|
||||
def collect(sym: str, rows: int, tf: str = "1m") -> 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.core.ChanEnum import Chan_BSP_TYPE
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
# 必须走 full:check_bi_div 读 macd_hist,那只在 full 下算。
|
||||
# 而 TF_DF 自己**不填** bsp_list——web 的 analyze.py 是显式调
|
||||
# find_all_bsp 的,这里照抄那条链,口径才对得上
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bi_zs = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bi_zs:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bi_zs)
|
||||
if not bsp:
|
||||
return None
|
||||
|
||||
# 索引映射有两个坑,都会静默给出错的下标:
|
||||
# ① cdf.date 带时区(Asia/Shanghai),而 KLC 上的时间是**字符串**且
|
||||
# 不带时区。它们本就是同一个时钟的墙上时间,所以去 tz 而不是硬贴。
|
||||
# ② cdf.date 的单位是 datetime64[ms],`astype("int64")` 给的是毫秒,
|
||||
# 而 `Timestamp.value` 是纳秒,差 1e6 倍——手工转整数会让
|
||||
# searchsorted 全部落到末尾,且不报错。交给 DatetimeIndex 自己比。
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
if t.tz is not None:
|
||||
t = t.tz_localize(None)
|
||||
return int(didx.searchsorted(t))
|
||||
|
||||
op = cdf["open"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1),
|
||||
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
|
||||
rec = []
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
# 极值根:笔末 KLC 的收盘时刻。KLC 是合并后的,取它覆盖的最后一根
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
a = atr[i_ext]
|
||||
if not np.isfinite(a) or a <= 0:
|
||||
continue
|
||||
# 理想价:极值根收盘。可执行价:确认根的**次根开盘**——与
|
||||
# walk_exits / 实盘的口径一致(信号根收盘后才下单)
|
||||
px_ideal = cl[i_ext]
|
||||
j = min(i_sure + 1, n - 1)
|
||||
px_real = op[j]
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": name, "dir": d,
|
||||
"date_ext": dser.iloc[i_ext], "date_sure": dser.iloc[i_sure],
|
||||
"i_ext": i_ext, "i_sure": i_sure,
|
||||
"lag_bars": i_sure - i_ext,
|
||||
"atr_bp": a / px_ideal * 1e4,
|
||||
# 结构位:笔末 KLC 的实际极值,不是收盘。止损要挂在它外面
|
||||
# 才谈得上「守住结构」,用收盘价会把插针那一段漏掉
|
||||
"ext_px": float(b.klc.low if d == 1 else b.klc.high),
|
||||
"entry_px": px_real,
|
||||
# 等待期间价格顺着信号方向跑掉了多少(正 = 你追高/追空,吃亏)
|
||||
"drift_atr": (px_real - px_ideal) * d / a,
|
||||
"drift_bp": (px_real - px_ideal) * d / px_ideal * 1e4,
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
# 入场口径与实盘一致:确认根收盘后下单,walk_exits 用 entry_idx+1 的
|
||||
# 开盘价,ATR 取 entry_idx 那根。所以 entry_idx = i_sure,**不是**
|
||||
# i_ext——用极值根等于假设你能买在笔的低点,那是未来函数
|
||||
r = r[(r.i_sure < len(cdf) - 2) & np.isfinite(atr[r.i_sure.values])
|
||||
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
sig = pd.DataFrame({"entry_idx": r.i_sure.values,
|
||||
"direction": r.dir.values})
|
||||
res = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
|
||||
runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
if len(res) != len(r):
|
||||
print(f" {sym} {tf} 长度不齐 {len(res)} vs {len(r)},跳过",
|
||||
flush=True)
|
||||
return None
|
||||
for k in ("g", "r", "c", "b"):
|
||||
r[k] = res[f"{cfg}_{k}"].to_numpy()
|
||||
# 反向对照:胜率远低于随机说明信号带信息但可能指反了。出场是路径依赖的,
|
||||
# 不能把 g 取负号了事,必须拿相反方向重跑一遍
|
||||
flip = pd.DataFrame({"entry_idx": r.i_sure.values,
|
||||
"direction": -r.dir.values})
|
||||
rf = walk_exits(cdf, flip, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
|
||||
runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(rf) == len(r):
|
||||
for k in ("g", "r", "c", "b"):
|
||||
r["f_" + k] = rf[f"{cfg}_{k}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
|
||||
# 原始 ATR,供结构止损换算用。atr_pct 除过价格,换不回来
|
||||
r["atr_at_entry"] = atr[r.i_sure.values]
|
||||
return r
|
||||
except Exception as e:
|
||||
print(f" {sym} 失败: {e!r}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame, span_days: float, n_sym: int) -> None:
|
||||
print("\n" + "=" * 100)
|
||||
print(f"########## 一、笔数:够不够做 ##########")
|
||||
t = d.groupby("type").agg(笔数=("lag_bars", "size"))
|
||||
t[f"{n_sym}币每天"] = (t["笔数"] / span_days).round(2)
|
||||
# 必须按币归一再比。B4 那个 5.3 是 10 币的合计,直接拿 5 币的数去除
|
||||
# 等于凭空把速率打对折
|
||||
t["每币每天"] = (t["笔数"] / span_days / n_sym).round(3)
|
||||
t["对B4倍数"] = (t["笔数"] / span_days / n_sym /
|
||||
(B4_PER_DAY_10SYM / 10)).round(2)
|
||||
print(t.to_string())
|
||||
print(f"\n对照基线:B4 在 1m 上 10 币 {B4_PER_DAY_10SYM} 笔/天 = "
|
||||
f"**{B4_PER_DAY_10SYM/10:.3f} 笔/币/天**。"
|
||||
f"本表 {n_sym} 币 / 跨 {span_days:.0f} 天。")
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 二、滞后:从极值到可动手,差多少根 ##########")
|
||||
rows = []
|
||||
for name, g in d.groupby("type"):
|
||||
q = g.lag_bars.quantile([0.25, 0.5, 0.75, 0.9]).round(1)
|
||||
rows.append({"类型": name, "笔数": len(g),
|
||||
"滞后中位": q[0.5], "P25": q[0.25], "P75": q[0.75],
|
||||
"P90": q[0.9], "均值": round(g.lag_bars.mean(), 1),
|
||||
"最大": int(g.lag_bars.max())})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n1m 上 1 根 = 1 分钟。B4 的滞后是 0 根——它在信号根收盘即可下单。")
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 三、等待的代价:价格跑掉了多少 ##########")
|
||||
print("drift 为正 = 等待期间价格顺着信号方向走了,你只能追;这一段直接从"
|
||||
"余量里扣")
|
||||
print("⚠️ 「追不上占比」必然是 100%,那是定义决定的不是实测发现:笔之所以"
|
||||
"在那里结束,\n 正是因为价格从那个极值反向走了——`bi.end_klc` 是极值"
|
||||
"点,之后必然朝信号方向偏离。\n **有信息量的是幅度,不是符号。**")
|
||||
rows = []
|
||||
for name, g in d.groupby("type"):
|
||||
q = g.drift_atr.quantile([0.25, 0.5, 0.75]).round(2)
|
||||
rows.append({
|
||||
"类型": name, "笔数": len(g),
|
||||
"漂移中位(ATR)": q[0.5], "P25": q[0.25], "P75": q[0.75],
|
||||
"漂移均值(ATR)": round(g.drift_atr.mean(), 2),
|
||||
"漂移均值(bp)": round(g.drift_bp.mean(), 1),
|
||||
"中位ATR(bp)": round(g.atr_bp.median(), 1),
|
||||
"追不上占比": f"{(g.drift_atr > 0).mean()*100:.0f}%"})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 四、和止损宽度比:漂移吃掉多少风险预算 ##########")
|
||||
print("实盘止损是 2 ATR。若漂移中位已经是 1 ATR,等于你的止损只剩一半,"
|
||||
"而目标位还在原处——盈亏比被腰斩。")
|
||||
rows = []
|
||||
for name, g in d.groupby("type"):
|
||||
rows.append({
|
||||
"类型": name,
|
||||
"漂移/止损(2ATR)": f"{g.drift_atr.median()/2*100:.0f}%",
|
||||
"漂移超过 1ATR 占比": f"{(g.drift_atr > 1).mean()*100:.0f}%",
|
||||
"漂移超过 2ATR(已穿止损)": f"{(g.drift_atr > 2).mean()*100:.0f}%"})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
def profit(d: pd.DataFrame) -> None:
|
||||
"""各类买卖点按实盘出场结构的实际表现。
|
||||
|
||||
B3 是校验锚:HANDOFF §4 已记引擎 `find_all_bsp` 的 B3/S3 是系统性亏损
|
||||
(PF 0.66、胜率 27.4%、t −18.76)。若这里 B3 跑出个漂亮数字,说明口径接错了,
|
||||
先别信 B1/B2 的结果。
|
||||
"""
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
if "g" not in d.columns:
|
||||
print("\n(本次数据无回测列,跳过收益段;删掉 out/step55_bsp12.feather "
|
||||
"重跑可得)")
|
||||
return
|
||||
x = d.dropna(subset=["g"]).copy()
|
||||
x["net"] = x.g.values - fee_of(x.r.values, x.c.values)
|
||||
x["gR"] = x.g.values / (SL * x.atr_pct.values)
|
||||
x["R"] = x.net.values / (SL * x.atr_pct.values)
|
||||
x["tn"] = taker_notional(x.r.values, x.c.values)
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 五、按实盘出场结构(2/3/8/2/48)的实际表现 ##########")
|
||||
rows = []
|
||||
for name, g in x.groupby("type"):
|
||||
if len(g) < 40:
|
||||
continue
|
||||
w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum()
|
||||
rows.append({
|
||||
"类型": name, "笔数": len(g),
|
||||
"胜率": 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,
|
||||
"R夏普": round(g.R.mean() / g.R.std(ddof=1), 3),
|
||||
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2),
|
||||
"中位持仓": int(g.b.median()),
|
||||
"t值": round(g.gR.mean() / (g.gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n⚠️ B3 是口径校验锚:HANDOFF §4 记的是 PF 0.66 / 胜率 27.4%。"
|
||||
"这里若明显更好,先怀疑接错了再信 B1/B2。")
|
||||
|
||||
if "f_g" not in x.columns:
|
||||
return
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 六、反向对照:信号是「指反了」还是「没有边」 ##########")
|
||||
print("胜率 18.6% 远低于 SL2/TP3 的随机基准约 40%,说明样本带信息。若只是"
|
||||
"方向标反了,\n反手做应显著为正;若反手也不赚,那就是入场时点本身"
|
||||
"已经过期,两边都吃滑点。")
|
||||
y = x.copy()
|
||||
y["fnet"] = y.f_g.values - fee_of(y.f_r.values, y.f_c.values)
|
||||
y["fgR"] = y.f_g.values / (SL * y.atr_pct.values)
|
||||
rows = []
|
||||
for name, g in y.groupby("type"):
|
||||
if len(g) < 40:
|
||||
continue
|
||||
w, o = g.fnet[g.fnet > 0].sum(), -g.fnet[g.fnet <= 0].sum()
|
||||
rows.append({
|
||||
"类型(反手)": name, "笔数": len(g),
|
||||
"胜率": f"{(g.fnet > 0).mean()*100:.1f}%",
|
||||
"毛R": round(g.fgR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"t值": round(g.fgR.mean() / (g.fgR.std(ddof=1) / np.sqrt(len(g))),
|
||||
2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m",
|
||||
help="要测的周期。1m 上滞后的绝对根数与长周期相同,"
|
||||
"但每根值的钱不同,所以周期是关键变量")
|
||||
ap.add_argument("--rows", type=int, default=200_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(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
print(f"[一二类可行性探针] {len(syms)} 币 × {tfs} × {args.rows} 根"
|
||||
f"(full 模式,find_all_bsp 要 macd_hist)\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, args.rows, t): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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_ext"] = pd.to_datetime(d["date_ext"])
|
||||
for tf, x in d.groupby("tf"):
|
||||
span = (x.date_ext.max() - x.date_ext.min()).total_seconds() / 86400
|
||||
print("\n" + "#" * 100)
|
||||
print(f"########## {tf} —— {len(x)} 个买卖点 · "
|
||||
f"{x.sym.nunique()} 币 · 跨 {span:.0f} 天 ##########")
|
||||
report(x, span, x.sym.nunique())
|
||||
profit(x)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""实时版一类买卖点(fast B1/S1)能否翻正。
|
||||
|
||||
step55 已判定引擎的 B1/S1 不可用(5m PF 0.24/0.20、胜率 18.6%/14.8%、
|
||||
t −17/−22),且病根是滞后 8~9 根带来的几何劣势,不是参数。B3 当年同病
|
||||
(PF 0.66),靠重新定义成实时判据翻到 1.59(B4)。本脚本对一类做同样的尝试。
|
||||
|
||||
对照组三条,缺一不可:
|
||||
引擎 B1/S1 step55 的数,说明「不改判据」是什么下场
|
||||
fast B1/S1 本次
|
||||
fast B3/S3 同一份数据、同一套出场跑 B4,确认管线本身能跑出正数
|
||||
—— 少了它,fast B1 若为负就分不清是判据不行还是管线接错了
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step56_fast_bsp1.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
|
||||
|
||||
def collect(sym: str, rows: int, tf: str) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
from lib.fast_bsp1 import find_fast_bsp1, zones_with_enter_area
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf)
|
||||
cdf = ensure_timestamp(chan.dataframe)
|
||||
zs_list = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zs_list:
|
||||
return None
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
parts = []
|
||||
|
||||
d1 = {}
|
||||
s1 = find_fast_bsp1(cdf, zones_with_enter_area(zs_list, cdf), diag=d1)
|
||||
if not s1.empty:
|
||||
s1["kind"] = "fastB1"
|
||||
parts.append(s1)
|
||||
# 同数据同出场跑一遍 B4,作为「管线能出正数」的存在性证明
|
||||
s3 = find_fast_bsp3(cdf, zones_from_zs_list(zs_list, cdf))
|
||||
if not s3.empty:
|
||||
s3["kind"] = "fastB3"
|
||||
parts.append(s3)
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
r = pd.concat(parts, ignore_index=True)
|
||||
r = r[(r.entry_idx < len(cdf) - 2)
|
||||
& np.isfinite(atr[r.entry_idx.values])
|
||||
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
res = walk_exits(cdf, r[["entry_idx", "direction"]], [SL], [RUNNER],
|
||||
[MAXB], scale_at=SCALE_AT, runners=(RUNNER,),
|
||||
runner_stops=(RSTOP,))
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for k in ("g", "r", "c", "b"):
|
||||
r[k] = res[f"{cfg}_{k}"].to_numpy()
|
||||
r["sym"], r["tf"] = sym, tf
|
||||
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
|
||||
r["date"] = cdf["date"].to_numpy()[r.entry_idx.values]
|
||||
r["diag"] = str(d1)
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def stats(g: pd.DataFrame) -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"R夏普": round(R.mean() / R.std(ddof=1), 3),
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"中位持仓": int(np.median(g.b.values)),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
for tf, x in d.groupby("tf"):
|
||||
span = (pd.to_datetime(x.date).max()
|
||||
- pd.to_datetime(x.date).min()).total_seconds() / 86400
|
||||
print("\n" + "#" * 92)
|
||||
print(f"########## {tf} · {x.sym.nunique()} 币 · 跨 {span:.0f} 天"
|
||||
f" ##########")
|
||||
rows = []
|
||||
for (kind, dirn), g in x.groupby(["kind", "direction"]):
|
||||
if len(g) < 40:
|
||||
continue
|
||||
nm = {"fastB1": ("一买", "一卖"), "fastB3": ("三买", "三卖")}[kind]
|
||||
rows.append({"信号": f"{kind} {nm[0 if dirn == 1 else 1]}",
|
||||
"每币每天": round(len(g) / span / x.sym.nunique(), 3),
|
||||
**stats(g)})
|
||||
if rows:
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n对照 · step55 引擎原生(同周期同出场):")
|
||||
print(" 5m B1 PF 0.24 胜率 18.6% t −17.3 | S1 PF 0.20 胜率 14.8% "
|
||||
"t −22.3")
|
||||
print(" 15m B1 PF 0.21 t −18.9 | S1 PF 0.23 t −15.9")
|
||||
|
||||
f1 = x[x.kind == "fastB1"]
|
||||
if len(f1) >= 120 and "ext_atr" in f1.columns:
|
||||
y = f1.dropna(subset=["ext_atr"])
|
||||
print("\n延伸度分档(ext_atr,step62 在滞后版上实测的唯一单调特征:"
|
||||
"\n命中线段顶点的比例 12%→44%,PF 0.12→0.46):")
|
||||
q = pd.qcut(y["ext_atr"], 4,
|
||||
labels=["Q1最短", "Q2", "Q3", "Q4最延伸"],
|
||||
duplicates="drop")
|
||||
print(pd.DataFrame([{"档": k, **stats(v)}
|
||||
for k, v in y.groupby(q, observed=True)
|
||||
if len(v) >= 30]).to_string(index=False))
|
||||
|
||||
print("\n⭐ 组合:低滞后(本模块)+ 延伸过滤(step62)—— "
|
||||
"唯一同时处理两个约束的路径")
|
||||
p50, p75 = y["ext_atr"].quantile(.50), y["ext_atr"].quantile(.75)
|
||||
dm = y["div"].median()
|
||||
rows = [{"过滤器": "无", **stats(y)}]
|
||||
for nm, g in [
|
||||
(f"ext≥P50({p50:.1f})", y[y["ext_atr"] >= p50]),
|
||||
(f"ext≥P75({p75:.1f})", y[y["ext_atr"] >= p75]),
|
||||
(f"ext≥P75 且 div≥中位", y[(y["ext_atr"] >= p75)
|
||||
& (y["div"] >= dm)]),
|
||||
(f"ext≥P50 且 div≥中位", y[(y["ext_atr"] >= p50)
|
||||
& (y["div"] >= dm)]),
|
||||
]:
|
||||
if len(g) >= 30:
|
||||
rows.append({"过滤器": nm, **stats(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("对照 · 同过滤器在滞后版(step62,5m):无 PF 0.22 → "
|
||||
"ext≥P75且div≥中位 PF 0.54")
|
||||
|
||||
if len(f1) >= 60:
|
||||
print("\n背驰强度分档(div = 离开段面积/进入段面积,越小背驰越强):")
|
||||
q = pd.qcut(f1["div"], 4, labels=["Q1最强", "Q2", "Q3", "Q4最弱"],
|
||||
duplicates="drop")
|
||||
print(pd.DataFrame([{"档": k, **stats(v)}
|
||||
for k, v in f1.groupby(q, observed=True)
|
||||
if len(v) >= 20]).to_string(index=False))
|
||||
print("\n滞后分档(lag = 入场根 − 离开段极值根):")
|
||||
b = pd.cut(f1["lag"], [-1, 1, 3, 6, 12, 1e9],
|
||||
labels=["≤1根", "2-3根", "4-6根", "7-12根", ">12根"])
|
||||
print(pd.DataFrame([{"档": k, **stats(v)}
|
||||
for k, v in f1.groupby(b, observed=True)
|
||||
if len(v) >= 20]).to_string(index=False))
|
||||
|
||||
dg = d[d.kind == "fastB1"].diag.dropna()
|
||||
if len(dg):
|
||||
print("\n" + "=" * 92)
|
||||
print("fast B1 漏斗(首个中枢样本):", dg.iloc[0])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_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(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
print(f"[实时版一类] {len(syms)} 币 × {tfs} × {args.rows} 根\n",
|
||||
flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, args.rows, t): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""一类反手(fade B1/S1)是不是真的——逐币、分时段、与 B4 的重叠。
|
||||
|
||||
step55 的反向对照给出 5m/15m 上 PF 2.35~3.03、t +10~+13,且多空两边都正。
|
||||
数字太好,先按 §3.392 的教训做证伪:那次「周末效应」在 1m 上显著,换到
|
||||
7 年 5m/15m 样本就消失且符号反转。任何单一样本上的漂亮结果都要先过三关。
|
||||
|
||||
逐币 5 个币是不是都成立,还是被某一个币带起来的
|
||||
分时段 前后半段样本各自是否成立(时间外样本)
|
||||
与 B4 重叠 如果它只是换个名字的 B4,那就没有增量价值
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
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))
|
||||
|
||||
from lib.exit_model import fee_of, taker_notional # noqa: E402
|
||||
|
||||
SRC = HERE / "out" / "step55_bsp12.feather"
|
||||
SL = 2.0
|
||||
|
||||
|
||||
def stats(g: pd.DataFrame) -> dict:
|
||||
"""反手口径的统计。f_* 是 step55 里用相反方向重跑出场得到的。"""
|
||||
net = g.f_g.values - fee_of(g.f_r.values, g.f_c.values)
|
||||
gR = g.f_g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.f_r.values, g.f_c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
d = pd.read_feather(SRC)
|
||||
if "f_g" not in d.columns:
|
||||
print("step55 数据里没有反手列,先重跑 step55")
|
||||
return
|
||||
d["date_ext"] = pd.to_datetime(d["date_ext"])
|
||||
one = d[d.type.isin(["B1", "S1"])].copy()
|
||||
|
||||
for tf, x in one.groupby("tf"):
|
||||
print("\n" + "#" * 92)
|
||||
print(f"########## {tf} · 一类反手 ##########")
|
||||
|
||||
print("\n【逐币】任何一个币独自撑起来的结果都不能要")
|
||||
print(pd.DataFrame([{"币": s, **stats(g)}
|
||||
for s, g in x.groupby("sym")]).to_string(index=False))
|
||||
|
||||
print("\n【分时段】按信号时间中位数切两半,后半段是时间外样本")
|
||||
cut = x.date_ext.median()
|
||||
for nm, g in (("前半", x[x.date_ext <= cut]), ("后半", x[x.date_ext > cut])):
|
||||
r = stats(g)
|
||||
print(f" {nm} {g.date_ext.min():%Y-%m-%d}~{g.date_ext.max():%Y-%m-%d}"
|
||||
f" {r}")
|
||||
|
||||
print("\n【分方向】B1反手=做空,S1反手=做多。只有一边成立就是单边行情")
|
||||
print(pd.DataFrame([{"原类型": t, **stats(g)}
|
||||
for t, g in x.groupby("type")]).to_string(index=False))
|
||||
|
||||
print("\n【逐年】")
|
||||
rows = []
|
||||
for y, g in x.groupby(x.date_ext.dt.year):
|
||||
if len(g) >= 40:
|
||||
rows.append({"年": y, **stats(g)})
|
||||
if rows:
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""结构止损:一类做多是被「止损太紧」打死的,还是趋势真的继续?
|
||||
|
||||
用户的观察:B1 做多的入场价 P 在结构低点 L 上方约 1.68 ATR,而固定止损是
|
||||
P − 2 ATR —— **只在 L 下方 0.32 ATR**。缠论里 B2 正是「回踩不破 L」的那次
|
||||
机会,属于预期之内的正常回抽。止损只留 0.32 ATR,一根插针就打掉,而那恰恰
|
||||
是该加仓的位置。
|
||||
|
||||
这与「趋势继续向下」是两个不同的失败模式,且**预测相反**:
|
||||
|
||||
止损太紧 把止损放到 L 下方足够远 -> 多头应被救活
|
||||
趋势继续 放宽止损只是亏得更多,且反手做空应持续为正
|
||||
|
||||
本脚本用逐笔的结构止损(挂在 L 外侧 margin 个 ATR)重跑,直接区分这两者。
|
||||
`walk_exits` 只支持全局固定止损,故这里自带模拟器;出场结构与实盘一致:
|
||||
减半于 SCALE_AT、剩余半仓目标 RUNNER、剩余半仓止损回到初始止损、MAXB 超时。
|
||||
"""
|
||||
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))
|
||||
|
||||
from lib.data import fetch_ohlcv # noqa: E402
|
||||
from lib.exit_model import fee_of, taker_notional # noqa: E402
|
||||
|
||||
SRC = HERE / "out" / "step55_bsp12.feather"
|
||||
SCALE_AT, RUNNER, MAXB = 3.0, 8.0, 48
|
||||
|
||||
|
||||
def simulate(cdf: pd.DataFrame, trades: pd.DataFrame,
|
||||
stop_atr: np.ndarray, r_scale: bool = True) -> pd.DataFrame:
|
||||
"""逐笔止损宽度的出场模拟。stop_atr 是每笔各自的初始止损(ATR 倍数)。
|
||||
|
||||
与 `lib.exit_model.walk_exits` 的结构对齐:先减半仓于 SCALE_AT,剩余半仓
|
||||
看 RUNNER 或回到初始止损;未减仓则整仓在止损/超时了结。同根内止损优先于
|
||||
获利目标 —— 分辨不了根内先后时,按不利的一侧算,不给回测送分。
|
||||
|
||||
`r_scale=True` 时目标按 **R 倍数**而非 ATR 固定值放置:实盘的 2/3/8 等于
|
||||
在 1.5R 减半、4R 收尾,放宽止损却不放大目标会把盈亏比压到 1 以下,
|
||||
那样测出来的「放宽无效」是自证的。默认按 R 等比放大才是公平对照。
|
||||
"""
|
||||
k_scale = SCALE_AT / 2.0 if r_scale else None
|
||||
k_run = RUNNER / 2.0 if r_scale else None
|
||||
high = cdf["high"].to_numpy(float)
|
||||
low = cdf["low"].to_numpy(float)
|
||||
open_ = cdf["open"].to_numpy(float)
|
||||
close = cdf["close"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
out = []
|
||||
ent = trades.entry_idx.astype(int).to_numpy()
|
||||
dirs = trades.direction.astype(int).to_numpy()
|
||||
atrs = trades.atr_at_entry.to_numpy(float)
|
||||
for k in range(len(trades)):
|
||||
s, d, a, sl = ent[k], dirs[k], atrs[k], stop_atr[k]
|
||||
e = s + 1
|
||||
if e >= n - 1 or not np.isfinite(sl) or sl <= 0 or not np.isfinite(a):
|
||||
out.append((np.nan, "skip", 0, 0))
|
||||
continue
|
||||
tgt = sl * k_scale if r_scale else SCALE_AT
|
||||
run = sl * k_run if r_scale else RUNNER
|
||||
entry = open_[e]
|
||||
end = min(e + MAXB, n - 1)
|
||||
half_done = False
|
||||
g, why, bars = None, "timeout", end - e
|
||||
for j in range(e, end + 1):
|
||||
adv = (high[j] - entry) / a if d == 1 else (entry - low[j]) / a
|
||||
ret = (entry - low[j]) / a if d == 1 else (high[j] - entry) / a
|
||||
if ret >= sl: # 同根内止损优先
|
||||
g = ((-sl * a) / entry) * (0.5 if half_done else 1.0)
|
||||
if half_done:
|
||||
g += (tgt * a / entry) * 0.5
|
||||
why, bars = "stop", j - e
|
||||
break
|
||||
if not half_done and adv >= tgt:
|
||||
half_done = True
|
||||
if half_done and adv >= run:
|
||||
g = (tgt * 0.5 + run * 0.5) * a / entry
|
||||
why, bars = "target", j - e
|
||||
break
|
||||
if g is None:
|
||||
px = close[end]
|
||||
r = (px - entry) * d / entry
|
||||
g = (tgt * a / entry) * 0.5 + r * 0.5 if half_done else r
|
||||
why, bars = ("timeout_half" if half_done else "timeout"), end - e
|
||||
out.append((g, why, int(half_done), bars))
|
||||
return pd.DataFrame(out, columns=["g", "r", "c", "b"])
|
||||
|
||||
|
||||
def stats(g: pd.Series, r: pd.Series, c: pd.Series, atr_pct: np.ndarray,
|
||||
sl_used: np.ndarray) -> dict:
|
||||
net = g.values - fee_of(r.values, c.values)
|
||||
# R 用每笔自己的止损宽度归一,否则宽止损会被系统性低估风险
|
||||
denom = sl_used * atr_pct
|
||||
R = net / denom
|
||||
tn = taker_notional(r.values, c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"净均R": round(np.nanmean(R), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(np.nanmean(R) / (np.nanstd(R, ddof=1)
|
||||
/ np.sqrt(len(R))), 2),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
# step65 发现线段中位幅度只有 11.4 ATR,而 r_scale 会把 3.9 ATR 的结构止损
|
||||
# 对应的 runner 目标推到 15.6 ATR —— 比整段行情还长,永远打不到。
|
||||
# 本脚本首轮全程用了 r_scale=True,等于「放宽止损」和「目标够不着」同时生效,
|
||||
# 两个效应互相抵消,那轮的结论无效。绝对目标才是与 11.4 ATR 相容的口径。
|
||||
ap.add_argument("--abs-targets", action="store_true",
|
||||
help="目标位用绝对 ATR(3/8)而非按止损等比放大")
|
||||
args = ap.parse_args()
|
||||
|
||||
d = pd.read_feather(SRC)
|
||||
if "ext_px" not in d.columns:
|
||||
print("step55 数据缺 ext_px/entry_px,先重跑 step55")
|
||||
return
|
||||
d = d[(d.tf == args.tf) & d.type.isin(["B1", "S1"])].copy()
|
||||
|
||||
print(f"[结构止损] {args.tf} · 一类 {len(d)} 笔\n")
|
||||
print("=" * 92)
|
||||
print("########## 一、几何:固定 2ATR 止损离结构位有多远 ##########")
|
||||
# 入场价到结构极值的距离,用入场根的 ATR 归一(与 walk_exits 同口径)
|
||||
d["to_ext"] = (d.entry_px - d.ext_px) * d.dir / d.atr_at_entry
|
||||
d["margin_2atr"] = 2.0 - d.to_ext
|
||||
print(f"入场价到结构极值 (ATR) 中位 {d.to_ext.median():.2f} "
|
||||
f"P25 {d.to_ext.quantile(.25):.2f} P75 {d.to_ext.quantile(.75):.2f}")
|
||||
print(f"2ATR 止损在结构位外侧留的余地 (ATR) 中位 "
|
||||
f"{d.margin_2atr.median():.2f}")
|
||||
inside = (d.margin_2atr <= 0).mean()
|
||||
print(f"**止损落在结构位以内(还没到极值就被打掉)的占比:{inside*100:.1f}%**")
|
||||
print(" —— 这部分交易,价格连回踩到前低都不用,就已经出局了。")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 二、把止损挂到结构位外侧,多头能不能救活 ##########")
|
||||
print("margin = 止损挂在结构极值外侧几个 ATR。对照组是现行的固定 2 ATR。")
|
||||
from chanlun import TF_DF
|
||||
rows = []
|
||||
cache = {}
|
||||
for sym in sorted(d.sym.unique()):
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
|
||||
cache[(sym, args.tf)] = TF_DF(df, 1, args.tf).dataframe
|
||||
|
||||
print(f"目标位口径:{'绝对 3/8 ATR(装得进 11.4 ATR 的线段)' if args.abs_targets else '随止损等比放大(恒定 1.5R/4R)'}")
|
||||
for flip in (False, True):
|
||||
for label, margin in [("固定2ATR", None), ("结构+0.25", 0.25),
|
||||
("结构+0.5", 0.5), ("结构+1.0", 1.0),
|
||||
("结构+1.5", 1.5)]:
|
||||
gs, rs, cs, ap_, sl_ = [], [], [], [], []
|
||||
for sym, x in d.groupby("sym"):
|
||||
cdf = cache[(sym, args.tf)]
|
||||
x = x.reset_index(drop=True)
|
||||
t = pd.DataFrame({
|
||||
"entry_idx": x.i_sure.values,
|
||||
"direction": (-x.dir.values if flip else x.dir.values),
|
||||
"atr_at_entry": x.atr_at_entry.values})
|
||||
# 反手时结构位在**盈利**方向,不能拿它当止损;止损改挂在
|
||||
# 入场价另一侧同样宽度处,否则两组比的不是同一个东西
|
||||
sl = (np.full(len(x), 2.0) if margin is None
|
||||
else (x.to_ext.values + margin))
|
||||
res = simulate(cdf, t, sl, r_scale=not args.abs_targets)
|
||||
ok = res.g.notna().values
|
||||
gs.append(res.g[ok])
|
||||
rs.append(res.r[ok])
|
||||
cs.append(res.c[ok])
|
||||
ap_.append((x.atr_at_entry.values / x.entry_px.values)[ok])
|
||||
sl_.append(sl[ok])
|
||||
rows.append({"方向": "反手" if flip else "原方向", "止损": label,
|
||||
"中位宽度ATR": round(float(np.median(
|
||||
np.concatenate(sl_))), 2),
|
||||
**stats(pd.concat(gs, ignore_index=True),
|
||||
pd.concat(rs, ignore_index=True),
|
||||
pd.concat(cs, ignore_index=True),
|
||||
np.concatenate(ap_), np.concatenate(sl_))})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:若「止损太紧」是主因,放宽到结构位外侧应让原方向 PF 越过 1。"
|
||||
"\n若原方向放宽后仍在 1 以下、而反手始终显著为正,"
|
||||
"说明趋势确实在继续,\n那么该做的是反手,加宽止损只是少亏一点。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""因果性回放:一类反手的信号在当时真的发得出来吗?
|
||||
|
||||
§3.395 的 PF 2.3~3.0 建立在全量数据一次算完的 `bsp_list` 上。但增量模块的
|
||||
文件头自己写着「笔必须整表重扫:**最后一笔 is_sure 允许收回**」,§5.41 也记了
|
||||
中枢右边缘会重画。若信号是事后才浮现的,那个 PF 就是幻觉。
|
||||
|
||||
**做法**:用 `init_stream` 预热,随后逐根 `append_bar`,每根之后重算
|
||||
`cal_bi_zs_list_pure` + `find_all_bsp`,记录每个信号**第一次出现**在哪一根。
|
||||
入场用那一根(的次根开盘),而不是事后的 `sure_time` —— 实盘只能这样。
|
||||
|
||||
必须逐根,不能分段重建:在第 t 根用 `data[0:t+S]` 重算等于多给了 S 根的信息,
|
||||
测出来的因果性是假的。
|
||||
|
||||
**三个要看的量**
|
||||
召回 全量算出的信号,有多少在回放中真的出现过(没出现的是事后才浮现)
|
||||
幻影 回放中出现、但全量里没有的(当时发了、后来被重画掉)
|
||||
代价 回放首现根 vs 全量 sure_time 的滞后;以及按首现根入场的实际 PF
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step59_replay.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
WANT = {"B1": 1, "S1": -1}
|
||||
|
||||
|
||||
def sig_key(b) -> tuple | None:
|
||||
"""信号身份用「类型 + 极值 KLC 的结束时刻」。
|
||||
|
||||
不能用 sure_time 当身份:它正是会被重画的字段,用它做键会把同一个信号
|
||||
在不同根上算成两个。极值点稳定得多。
|
||||
"""
|
||||
t = getattr(b.type, "name", str(b.type))
|
||||
if t not in WANT:
|
||||
return None
|
||||
return (t, str(b.klc.end_time))
|
||||
|
||||
|
||||
def replay(sym: str, tf: str, rows: int, warm: int, steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < warm + steps + 100:
|
||||
print(f" {sym} {tf} 数据不足 {0 if df is None else len(df)}", flush=True)
|
||||
return None
|
||||
df = df.iloc[-(warm + steps):].reset_index(drop=True)
|
||||
|
||||
# ---- 全量口径:一次算完,作为对照 ----
|
||||
full = TF_DF(df, 1, tf, lean=False)
|
||||
fz = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
full_sig = {}
|
||||
for b in (full.find_all_bsp(full.bi_list, fz) or []):
|
||||
k = sig_key(b)
|
||||
if k and b.sure_time is not None:
|
||||
full_sig[k] = str(b.sure_time)
|
||||
|
||||
# ---- 回放口径:逐根追加,记录首现根 ----
|
||||
chan = TF_DF(df.iloc[:warm].copy(), 1, tf, lean=False)
|
||||
chan.init_stream(df.iloc[:warm].copy(), 1, tf)
|
||||
first_seen: dict[tuple, int] = {}
|
||||
for i in range(warm, len(df)):
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zs = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
bsp = chan.find_all_bsp(chan.bi_list, zs) if zs else []
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for b in (bsp or []):
|
||||
k = sig_key(b)
|
||||
if k and k not in first_seen:
|
||||
first_seen[k] = i
|
||||
|
||||
dser = pd.to_datetime(full.dataframe["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
rec = []
|
||||
for k in set(full_sig) | set(first_seen):
|
||||
t, ext_t = k
|
||||
i_seen = first_seen.get(k)
|
||||
# 只统计回放窗口内的:预热段的信号本来就不在考察范围
|
||||
i_ext = to_i(ext_t)
|
||||
if i_ext < warm - 200:
|
||||
continue
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": t, "dir": WANT[t],
|
||||
"in_full": k in full_sig, "in_replay": i_seen is not None,
|
||||
"i_ext": i_ext,
|
||||
"i_seen": -1 if i_seen is None else i_seen,
|
||||
"i_sure_full": to_i(full_sig[k]) if k in full_sig else -1,
|
||||
})
|
||||
r = pd.DataFrame(rec)
|
||||
if r.empty:
|
||||
return None
|
||||
|
||||
# 按回放首现根入场,跑与实盘一致的出场
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
cdf = full.dataframe
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
live = r[r.in_replay & (r.i_seen < len(cdf) - 2)].copy()
|
||||
live = live[np.isfinite(atr[live.i_seen.values])
|
||||
& (atr[live.i_seen.values] > 0)]
|
||||
if not live.empty:
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
# 反手:§3.395 判定该反着做
|
||||
t_ = pd.DataFrame({"entry_idx": live.i_seen.values,
|
||||
"direction": -live.dir.values})
|
||||
res = walk_exits(cdf, t_, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
|
||||
runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(live):
|
||||
for c in ("g", "r", "c"):
|
||||
live[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
live["atr_pct"] = atr[live.i_seen.values] / cl[live.i_seen.values]
|
||||
r = r.merge(live[["i_ext", "type", "g", "r", "c", "atr_pct"]],
|
||||
on=["i_ext", "type"], how="left")
|
||||
return r
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 一、召回与幻影 ##########")
|
||||
rows = []
|
||||
for (tf, t), x in d.groupby(["tf", "type"]):
|
||||
full = x[x.in_full]
|
||||
rep = x[x.in_replay]
|
||||
both = x[x.in_full & x.in_replay]
|
||||
rows.append({
|
||||
"tf": tf, "类型": t,
|
||||
"全量信号": len(full), "回放信号": len(rep),
|
||||
"召回": f"{len(both)/max(len(full),1)*100:.1f}%",
|
||||
"事后才浮现": len(full) - len(both),
|
||||
"幻影(被重画掉)": len(rep) - len(both),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n召回 = 全量算出的信号里,回放中真的出现过的比例。"
|
||||
"\n幻影 = 回放中发过、全量里却没有的 —— 实盘会照做,回测却看不见它。")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 二、时点代价:回放首现 vs 全量 sure_time ##########")
|
||||
b = d[d.in_full & d.in_replay].copy()
|
||||
b["delay"] = b.i_seen - b.i_sure_full
|
||||
for tf, x in b.groupby("tf"):
|
||||
q = x.delay.quantile([.25, .5, .75, .9])
|
||||
print(f" {tf} 中位 {q[.5]:+.0f} 根 P25 {q[.25]:+.0f} "
|
||||
f"P75 {q[.75]:+.0f} P90 {q[.9]:+.0f} "
|
||||
f"| 早于或等于全量的占比 {(x.delay <= 0).mean()*100:.0f}%")
|
||||
print(" 正值 = 回放比全量晚知道,实盘要在更差的价位入场。")
|
||||
|
||||
if "g" not in d.columns:
|
||||
return
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 三、真正能落地的收益:按回放首现根入场(反手)##########")
|
||||
x = d.dropna(subset=["g"]).copy()
|
||||
rows = []
|
||||
for (tf, t), g in x.groupby(["tf", "type"]):
|
||||
if len(g) < 30:
|
||||
continue
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
rows.append({
|
||||
"tf": tf, "类型(反手)": t, "笔数": len(g),
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
})
|
||||
if rows:
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n对照 · §3.395 全量口径 5m:B1反手 PF 2.35 / S1反手 2.75")
|
||||
print("若这里明显掉下来,说明那个 PF 吃了右边缘重画的红利,不可落地。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL")
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=60_000)
|
||||
ap.add_argument("--warm", type=int, default=20_000)
|
||||
ap.add_argument("--steps", type=int, default=10_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():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [x.strip() for x in args.symbols.split(",")]
|
||||
print(f"[因果回放] {len(syms)} 币 × {args.steps} 根逐根重放"
|
||||
f"(每根都要重算笔中枢与 bsp,慢是必然的)\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.tf, args.rows, args.warm,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,242 @@
|
||||
"""一类买卖点到底有没有找到真正的趋势反转点?用线段顶点当标准答案。
|
||||
|
||||
用户提出:线段的终点就是该级别的趋势反转点,虽然它是未来函数,但可以拿来当
|
||||
**标签**验证检测器,而不是拿来交易。这能把两件事分开:
|
||||
|
||||
检测器对不对 B1 是否真的落在趋势反转底上
|
||||
能不能交易 step59 已证否(实盘首现比 sure_time 晚中位 15 根,PF 塌到 0.92)
|
||||
|
||||
若检测器对而只是慢,那问题是延迟,还有救;若检测器本身就没找到反转点,
|
||||
这条线整个是死的。
|
||||
|
||||
⚠️ **对照组是这个测试的全部意义**。B1 按构造就长在笔的低点上,而笔低点本来
|
||||
就有一定概率撞上线段底。所以要问的不是「B1 命中率多少」,而是
|
||||
**「在所有同向笔端点里,B1 这个标签把命中率提高了多少倍」**。
|
||||
没有这个基准,任何绝对数字都可以随便解读。
|
||||
(同样的坑 fast_bsp 文档头踩过:回抽极值命中笔端点 19.3%,看着不低,
|
||||
但随机基准是 22%,其实是负贡献。)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step60_seg_truth.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
TOL = [0, 1, 2, 3, 5]
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
# ---- 标准答案:线段终点。下降线段终点=真底,上升线段终点=真顶 ----
|
||||
seg_bot, seg_top = [], []
|
||||
for sg in getattr(chan, "seg_list", []) or []:
|
||||
if sg.end_time is None:
|
||||
continue
|
||||
i = to_i(sg.end_time)
|
||||
if not (0 <= i < n):
|
||||
continue
|
||||
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN else seg_top).append(i)
|
||||
if not seg_bot or not seg_top:
|
||||
return None
|
||||
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
|
||||
|
||||
def near(i: int, d: int, tol: int) -> bool:
|
||||
a = truth[d]
|
||||
k = int(np.searchsorted(a, i))
|
||||
for j in (k - 1, k):
|
||||
if 0 <= j < len(a) and abs(int(a[j]) - i) <= tol:
|
||||
return True
|
||||
return False
|
||||
|
||||
rec = []
|
||||
# ---- 对照组:所有笔端点。B1 本就长在笔低点上,基准必须同源 ----
|
||||
for bi in chan.bi_list:
|
||||
if not getattr(bi, "is_sure", False) or bi.end_klc is None:
|
||||
continue
|
||||
d = 1 if str(bi.dir).endswith("DOWN") else -1 # 下降笔终点=低点
|
||||
i = to_i(bi.end_klc.end_time)
|
||||
if not (0 <= i < n):
|
||||
continue
|
||||
rec.append({"kind": "笔端点", "dir": d, "i_ext": i, "i_sure": -1})
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
rec.append({"kind": name, "dir": d, "i_ext": i_ext,
|
||||
"i_sure": i_sure})
|
||||
|
||||
r = pd.DataFrame(rec)
|
||||
for tol in TOL:
|
||||
r[f"hit{tol}"] = [near(i, d, tol)
|
||||
for i, d in zip(r.i_ext, r.dir)]
|
||||
|
||||
# 命中线段顶点的那批一类,按原方向(抄底)做能不能赚
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
sig = r[(r.kind.isin(["B1", "S1"])) & (r.i_sure >= 0)
|
||||
& (r.i_sure < n - 2)].copy()
|
||||
sig = sig[np.isfinite(atr[sig.i_sure.values])
|
||||
& (atr[sig.i_sure.values] > 0)]
|
||||
if not sig.empty:
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": sig.i_sure.values,
|
||||
"direction": sig.dir.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(sig):
|
||||
for c in ("g", "r", "c"):
|
||||
sig[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
sig["atr_pct"] = (atr[sig.i_sure.values]
|
||||
/ cl[sig.i_sure.values])
|
||||
r = r.merge(sig[["i_ext", "kind", "g", "r", "c", "atr_pct"]],
|
||||
on=["i_ext", "kind"], how="left")
|
||||
r["sym"], r["tf"] = sym, tf
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 92)
|
||||
print(f"########## {tf} · 线段顶点作为标准答案 ##########")
|
||||
print("\n【命中率】i_ext 落在同向线段终点 ±tol 根内的比例")
|
||||
rows = []
|
||||
for kind in ["笔端点", "B1", "S1", "B3", "S3"]:
|
||||
g = x[x.kind == kind]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
row = {"信号": kind, "样本": len(g)}
|
||||
for tol in TOL:
|
||||
row[f"±{tol}根"] = f"{g[f'hit{tol}'].mean()*100:.1f}%"
|
||||
rows.append(row)
|
||||
t = pd.DataFrame(rows)
|
||||
print(t.to_string(index=False))
|
||||
|
||||
base = x[x.kind == "笔端点"]
|
||||
print("\n【提升倍数】相对「所有同向笔端点」这个基准。"
|
||||
"≈1 就是没有信息量")
|
||||
rows = []
|
||||
for kind in ["B1", "S1", "B3", "S3"]:
|
||||
g = x[x.kind == kind]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
row = {"信号": kind}
|
||||
for tol in TOL:
|
||||
b = base[base.dir.isin(g.dir.unique())][f"hit{tol}"].mean()
|
||||
row[f"±{tol}根"] = (round(g[f"hit{tol}"].mean() / b, 2)
|
||||
if b > 0 else np.nan)
|
||||
rows.append(row)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
if "g" not in x.columns:
|
||||
continue
|
||||
print("\n【命中 vs 未命中】一类按原方向(抄底/摸顶)做的表现,"
|
||||
"tol=±2 根")
|
||||
y = x[x.kind.isin(["B1", "S1"])].dropna(subset=["g"]).copy()
|
||||
if len(y) < 60:
|
||||
continue
|
||||
rows = []
|
||||
for hit, g in y.groupby(y.hit2):
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
rows.append({
|
||||
"命中线段顶点": "是" if hit else "否", "笔数": len(g),
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1)
|
||||
/ np.sqrt(len(g))), 2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:若「命中」那组按原方向做显著为正,说明检测器是对的、"
|
||||
"只是掺了太多噪声,\n值得找实时可判的过滤器;若两组都为负,"
|
||||
"说明即使真站在线段底上,\n这个入场时点也已经太晚了。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_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():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,252 @@
|
||||
"""极值点的波动率:它是不是既拖慢了确认,又让目标够不着?
|
||||
|
||||
用户的观察:一二类买卖点长在极值点上,那个区域波动天然很大,这可能正是确认
|
||||
慢的原因。
|
||||
|
||||
这个假设若成立会同时解释两件事,而且机制不同:
|
||||
|
||||
确认慢 波动大 -> 分型/笔要更多根才稳定下来 -> sure_time 更晚
|
||||
赚不到 ATR 被造成极值的那根插针抬高 -> 止损 2×虚高ATR 其实宽松,
|
||||
但目标 3/8 ATR 变得够不着,因为入场后波动率会均值回复下来
|
||||
|
||||
第二条尤其要紧:它意味着交易不是「被打掉」,而是「永远走不到目标」,
|
||||
与 §3.397 判定的「趋势继续」是**不同的失败模式**,应对办法也不同
|
||||
(该换波动率口径,而不是换方向)。
|
||||
|
||||
三个量:
|
||||
atr_z 极值处 ATR / 该点之前 200 根的中位 ATR —— 是否真的偏高
|
||||
atr_fwd 入场后 48 根的平均 ATR / 入场时 ATR —— 是否均值回复
|
||||
与滞后相关 atr_z 高的信号,lag_bars 是否更长
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step61_vol.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
BASE_WIN = 200
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
# 基准 ATR 只用**该点之前**的窗口,含当根会把要检验的那根插针算进去
|
||||
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
|
||||
.median().shift(1).to_numpy())
|
||||
|
||||
rec = []
|
||||
# 对照组:所有笔端点,与 §3.397 同源
|
||||
for bi in chan.bi_list:
|
||||
if not getattr(bi, "is_sure", False) or bi.end_klc is None:
|
||||
continue
|
||||
i = to_i(bi.end_klc.end_time)
|
||||
if not (0 <= i < n) or not np.isfinite(base[i]) or base[i] <= 0:
|
||||
continue
|
||||
rec.append({"kind": "笔端点", "dir": 0, "i_ext": i, "i_sure": -1,
|
||||
"atr_z": atr[i] / base[i], "lag_bars": -1})
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1),
|
||||
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
if not np.isfinite(base[i_ext]) or base[i_ext] <= 0:
|
||||
continue
|
||||
rec.append({"kind": name, "dir": d, "i_ext": i_ext,
|
||||
"i_sure": i_sure, "atr_z": atr[i_ext] / base[i_ext],
|
||||
"lag_bars": i_sure - i_ext})
|
||||
|
||||
r = pd.DataFrame(rec)
|
||||
# 入场后的实现波动率:目标够不够得着,取决于入场**之后**的 ATR
|
||||
ok = r.i_sure >= 0
|
||||
fwd = np.full(len(r), np.nan)
|
||||
for k, i in zip(np.where(ok)[0], r.i_sure[ok].values):
|
||||
j = min(int(i) + 1 + MAXB, n)
|
||||
if j > int(i) + 1 and atr[int(i)] > 0:
|
||||
fwd[k] = np.nanmean(atr[int(i) + 1:j]) / atr[int(i)]
|
||||
r["atr_fwd"] = fwd
|
||||
|
||||
sig = r[ok & (r.i_sure < n - 2)].copy()
|
||||
sig = sig[np.isfinite(atr[sig.i_sure.values])
|
||||
& (atr[sig.i_sure.values] > 0)]
|
||||
if not sig.empty:
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": sig.i_sure.values,
|
||||
"direction": sig.dir.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(sig):
|
||||
for c in ("g", "r", "c", "b"):
|
||||
sig[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
sig["atr_pct"] = (atr[sig.i_sure.values]
|
||||
/ cl[sig.i_sure.values])
|
||||
r = r.merge(sig[["i_ext", "kind", "g", "r", "c", "b",
|
||||
"atr_pct"]], on=["i_ext", "kind"], how="left")
|
||||
r["sym"], r["tf"] = sym, tf
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 96)
|
||||
print(f"########## {tf} · 极值点的波动率 ##########")
|
||||
|
||||
print("\n【一】极值处 ATR 是不是真的偏高(atr_z = 当点ATR / 前200根中位ATR)")
|
||||
rows = []
|
||||
for kind in ["笔端点", "B1", "S1", "B2", "S2", "B3", "S3"]:
|
||||
g = x[x.kind == kind]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
rows.append({
|
||||
"信号": kind, "样本": len(g),
|
||||
"atr_z中位": round(g.atr_z.median(), 3),
|
||||
"P75": round(g.atr_z.quantile(.75), 3),
|
||||
"P90": round(g.atr_z.quantile(.90), 3),
|
||||
"高于基准占比": f"{(g.atr_z > 1).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n【二】波动大是不是确认更慢(按 atr_z 四分位看 lag_bars)")
|
||||
y = x[x.kind.isin(["B1", "S1"]) & (x.lag_bars >= 0)].copy()
|
||||
if len(y) >= 100:
|
||||
q = pd.qcut(y.atr_z, 4, labels=["Q1低", "Q2", "Q3", "Q4高"],
|
||||
duplicates="drop")
|
||||
print(pd.DataFrame([{
|
||||
"atr_z档": k, "笔数": len(g),
|
||||
"atr_z中位": round(g.atr_z.median(), 2),
|
||||
"滞后中位": round(g.lag_bars.median(), 1),
|
||||
"滞后均值": round(g.lag_bars.mean(), 1),
|
||||
} for k, g in y.groupby(q, observed=True)]).to_string(index=False))
|
||||
c = np.corrcoef(y.atr_z, y.lag_bars)[0, 1]
|
||||
print(f" 相关系数 corr(atr_z, lag_bars) = {c:+.3f}")
|
||||
print(" 正相关支持「波动大 -> 确认慢」;接近 0 则该假设不成立。")
|
||||
|
||||
print("\n【三】入场后波动率是否回落(atr_fwd = 后48根平均ATR / 入场ATR)")
|
||||
rows = []
|
||||
for kind in ["B1", "S1", "B3", "S3"]:
|
||||
g = x[(x.kind == kind)].dropna(subset=["atr_fwd"])
|
||||
if len(g) < 30:
|
||||
continue
|
||||
rows.append({
|
||||
"信号": kind, "样本": len(g),
|
||||
"atr_fwd中位": round(g.atr_fwd.median(), 3),
|
||||
"回落占比(<1)": f"{(g.atr_fwd < 1).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print(" <1 = 入场后波动率比入场那刻低,则按入场ATR定的 3/8 ATR 目标"
|
||||
"\n 在绝对价格上被高估,会系统性够不着。")
|
||||
|
||||
if "g" not in x.columns:
|
||||
continue
|
||||
print("\n【四】目标够不着的证据:出场原因分布(一类,按原方向)")
|
||||
z = x[x.kind.isin(["B1", "S1"])].dropna(subset=["g"])
|
||||
if len(z) >= 60:
|
||||
print((z.r.value_counts(normalize=True) * 100).round(1)
|
||||
.to_frame("占比%").to_string())
|
||||
print(f" 中位持仓 {z.b.median():.0f} 根 / 上限 {MAXB} 根")
|
||||
|
||||
print("\n【五】按 atr_z 分档看一类表现(原方向)")
|
||||
q = pd.qcut(z.atr_z, 4, labels=["Q1低", "Q2", "Q3", "Q4高"],
|
||||
duplicates="drop")
|
||||
rows = []
|
||||
for k, g in z.groupby(q, observed=True):
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
rows.append({
|
||||
"atr_z档": k, "笔数": len(g),
|
||||
"atr_z中位": round(g.atr_z.median(), 2),
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_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():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,271 @@
|
||||
"""怎么把「趋势末端的一类」从「趋势中途的一类」里挑出来。
|
||||
|
||||
§3.397 的关键数字:一类里命中线段顶点(真反转)的只有 30%,那 30% 即使带着
|
||||
8~9 根滞后也有 PF 0.70~0.83;**没命中的 70% 是 PF 0.08**。
|
||||
所以亏损几乎全部来自被误识别在趋势中途的那批 —— 用户的判断。
|
||||
|
||||
于是问题变成:有没有**实时可算**的特征能把两批分开。
|
||||
|
||||
**首要候选来自缠论本身**:一类买点要求的是**趋势背驰**,而趋势的定义是
|
||||
「至少两个同向连续的中枢」。引擎的 `find_all_bsp` 对**任意**中枢都发信号,
|
||||
完全没查这个前提 —— 单个盘整中枢上的「背驰」只是盘整背驰,本就不该当一类用。
|
||||
`fast_bsp.add_zone_ladder` 早就实现了这个判定(B4 上把 PF 2.72 提到 3.41),
|
||||
一类这边却没接。
|
||||
|
||||
测的特征全部只用信号时刻及之前的数据:
|
||||
|
||||
ladder 本中枢相对前一中枢是否同向推进(下降趋势要求 zg < 前一个 zd)
|
||||
zs_count 该中枢在本段里的序号,越大趋势越成熟
|
||||
div 离开段 MACD 面积 / 进入段面积,越小背驰越强
|
||||
ext_run 极值越过中枢边界多少个 ATR,越大越延伸
|
||||
atr_z 极值处波动率(§3.398)
|
||||
|
||||
评判分两层:**能否提高命中线段顶点的概率**(检测器精度),
|
||||
以及**能否提高实际收益**(可交易性)。前者好后者不好也没用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step62_trend_end.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
BASE_WIN, TOL = 200, 2
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
|
||||
.median().shift(1).to_numpy())
|
||||
|
||||
# 标准答案:线段终点(未来函数,只当标签用,不进入任何过滤器)
|
||||
seg_bot, seg_top = [], []
|
||||
for sg in getattr(chan, "seg_list", []) or []:
|
||||
if sg.end_time is None:
|
||||
continue
|
||||
i = to_i(sg.end_time)
|
||||
if 0 <= i < n:
|
||||
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN
|
||||
else seg_top).append(i)
|
||||
if not seg_bot or not seg_top:
|
||||
return None
|
||||
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
|
||||
|
||||
def near(i: int, d: int) -> bool:
|
||||
a = truth[d]
|
||||
k = int(np.searchsorted(a, i))
|
||||
return any(0 <= j < len(a) and abs(int(a[j]) - i) <= TOL
|
||||
for j in (k - 1, k))
|
||||
|
||||
# 中枢阶梯:按可用顺序排好,才谈得上「相对前一个」
|
||||
zs_seq = sorted(bz, key=lambda z: to_i(z.bi_list[0].start_time))
|
||||
pos = {id(z): k for k, z in enumerate(zs_seq)}
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1)}
|
||||
rec = []
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None or b.zs is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
a = atr[i_ext]
|
||||
if not np.isfinite(a) or a <= 0 or not np.isfinite(base[i_ext]):
|
||||
continue
|
||||
zs = b.zs
|
||||
k = pos.get(id(zs))
|
||||
# 趋势成熟度:本中枢往前数,连续同向推进的中枢有几个。
|
||||
# 单看「相对前一个是否同向」没有区分力 —— B1 要求
|
||||
# enter_bi.dir == leave_bi.dir == DOWN,即中枢向下进、向下出,
|
||||
# 这本身就定义了它嵌在下跌趋势里,连续纯中枢自然逐级下移,
|
||||
# 实测该条件在 2504 笔上恒为 True。**引擎已隐含强制了「趋势」前提。**
|
||||
# 有区分力的是「连了几级」,那才是趋势成熟度。
|
||||
ladder_n = 0
|
||||
if k is not None:
|
||||
j = k
|
||||
while j > 0:
|
||||
cur, prv = zs_seq[j], zs_seq[j - 1]
|
||||
ok = (float(cur.zg) < float(prv.zd) if d == 1
|
||||
else float(cur.zd) > float(prv.zg))
|
||||
if not ok:
|
||||
break
|
||||
ladder_n += 1
|
||||
j -= 1
|
||||
enter_bi = zs.bi_list[0].pre if zs.bi_list else None
|
||||
ea = abs(float(enter_bi.macd_hist)) if enter_bi is not None else np.nan
|
||||
la = abs(float(b.bi.macd_hist))
|
||||
edge = float(zs.zd) if d == 1 else float(zs.zg)
|
||||
ext = float(b.klc.low if d == 1 else b.klc.high)
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": name, "dir": d,
|
||||
"i_ext": i_ext, "i_sure": i_sure,
|
||||
"lag_bars": i_sure - i_ext,
|
||||
"hit": near(i_ext, d),
|
||||
"ladder_n": int(ladder_n),
|
||||
"div": la / ea if (ea and np.isfinite(ea) and ea > 0) else np.nan,
|
||||
"ext_run": abs(ext - edge) / a,
|
||||
"atr_z": a / base[i_ext],
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
r = r[(r.i_sure < n - 2) & np.isfinite(atr[r.i_sure.values])
|
||||
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.i_sure.values, "direction": r.dir.values}),
|
||||
[SL], [RUNNER], [MAXB], scale_at=SCALE_AT, runners=(RUNNER,),
|
||||
runner_stops=(RSTOP,))
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c", "b"):
|
||||
r[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "命中率": f"{g.hit.mean()*100:.1f}%",
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 96)
|
||||
print(f"########## {tf} · {len(x)} 笔一类 ##########")
|
||||
|
||||
print("\n【一】单特征对「命中线段顶点」的区分力(命中率基准 "
|
||||
f"{x.hit.mean()*100:.1f}%)")
|
||||
rows = []
|
||||
for nm, col, qs in [("背驰div", "div", 4), ("延伸ext_run", "ext_run", 4),
|
||||
("波动atr_z", "atr_z", 4), ("趋势级数ladder_n", "", 0)]:
|
||||
if not col:
|
||||
q = pd.cut(x["ladder_n"], [-1, 1, 2, 3, 999],
|
||||
labels=["级数≤1", "=2", "=3", "≥4"])
|
||||
for k, g in x.groupby(q, observed=True):
|
||||
if len(g) >= 30:
|
||||
rows.append({"分组": str(k), **perf(g)})
|
||||
continue
|
||||
y = x.dropna(subset=[col])
|
||||
if len(y) < 100:
|
||||
continue
|
||||
q = pd.qcut(y[col], qs,
|
||||
labels=[f"{nm}Q{i+1}" for i in range(qs)],
|
||||
duplicates="drop")
|
||||
for k, g in y.groupby(q, observed=True):
|
||||
if len(g) >= 30:
|
||||
rows.append({"分组": str(k), **perf(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n【二】叠加过滤:延伸是唯一单调的特征,看叠加还能不能推上去")
|
||||
rows = [{"过滤器": "无(现状)", **perf(x)}]
|
||||
e75 = x["ext_run"].quantile(.75)
|
||||
e50 = x["ext_run"].quantile(.50)
|
||||
for nm, g in [
|
||||
(f"ext_run≥P50({e50:.1f})", x[x["ext_run"] >= e50]),
|
||||
(f"ext_run≥P75({e75:.1f})", x[x["ext_run"] >= e75]),
|
||||
(f"ext_run≥P75 且 级数≥3",
|
||||
x[(x["ext_run"] >= e75) & (x["ladder_n"] >= 3)]),
|
||||
(f"ext_run≥P75 且 div≥中位",
|
||||
x[(x["ext_run"] >= e75) & (x["div"] >= x["div"].median())]),
|
||||
]:
|
||||
if len(g) >= 30:
|
||||
rows.append({"过滤器": nm, **perf(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:命中率若被显著抬高,说明特征确实在区分「趋势末端 vs 中途」。"
|
||||
"\n但 PF 才是能不能做的判据 —— 命中率上去而 PF 不过 1,"
|
||||
"说明滞后仍然吃掉了全部。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_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():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""B4 主线的因果回放:实盘信号的时点是不是干净的。
|
||||
|
||||
step59 在一类上抓到一个会骗人的坑:`sure_time` 是引擎**事后**标注的确认时刻,
|
||||
不等于可执行时刻,用它当 entry 的回测 PF 虚高一倍以上。B4 正在跑真钱,
|
||||
必须过同一关。
|
||||
|
||||
**先验比一类好,但方向要说清**(§5.41 的代码审计):
|
||||
|
||||
一类 `sure_time` 直接当**入场时刻** -> 早了就是虚高,回测被高估
|
||||
B4 `available_ts` 只是**扫描起点** -> 晚了只会漏信号,回测偏保守
|
||||
|
||||
§5.41 实测全量的 `available_ts` 系统性**更晚**(`bis[-1]` 取的是中枢结束而非
|
||||
形成,中位晚 62 分钟)。所以预期是「回测保守」而非「回测虚高」。但那是 300
|
||||
时点抽样 + 代码审计,不是逐根验证,而且留了个未知:
|
||||
**实盘会产出更多、更早的信号,那部分的质量不在回测统计里。**
|
||||
|
||||
本脚本逐根重放,同时量两边:
|
||||
|
||||
准时率 全量信号在其 entry_idx 当根就能算出来的比例
|
||||
迟到 首现晚于 entry_idx 的,实盘只能在更差的价位追
|
||||
额外信号 回放发得出、全量却没有的 —— §5.41 预言存在,但没人统计过它们赚不赚
|
||||
|
||||
⚠️ 性能:每根扫全部中枢跑不完。一个中枢只能在其 available_ts 之后 scan 根内
|
||||
出信号,所以每根只需把窗口内的中枢喂给 `find_fast_bsp3`。这是等价裁剪,
|
||||
不改变结果。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step63_b4_replay.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
SCAN = 200
|
||||
|
||||
|
||||
def replay(sym: str, tf: str, rows: int, warm: int,
|
||||
steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < warm + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(warm + steps):].reset_index(drop=True)
|
||||
|
||||
# ---- 全量口径:回测就是这么算的 ----
|
||||
full = TF_DF(df, 1, tf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zs_full = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
if not zs_full:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zs_full, cdf))
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
|
||||
# ---- 回放口径:逐根重算 zones 再扫 ----
|
||||
chan = TF_DF(df.iloc[:warm].copy(), 1, tf)
|
||||
chan.init_stream(df.iloc[:warm].copy(), 1, tf)
|
||||
first_seen: dict[tuple, int] = {}
|
||||
for i in range(warm, len(df)):
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z.empty:
|
||||
continue
|
||||
# 等价裁剪:available_ts 早于 scan 根之前的中枢,其扫描窗口
|
||||
# 已经过去,不可能在本根产出新信号
|
||||
lo = sub["timestamp"].to_numpy()[max(0, len(sub) - SCAN - 2)]
|
||||
z = z[z.available_ts >= lo]
|
||||
if z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z, scan=SCAN)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (int(r.entry_idx), int(r.direction))
|
||||
if k not in first_seen:
|
||||
first_seen[k] = i
|
||||
|
||||
rec = []
|
||||
for k in set(full_keys) | set(first_seen):
|
||||
e, d = k
|
||||
if e < warm: # 预热段不计入
|
||||
continue
|
||||
seen = first_seen.get(k)
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "entry_idx": e, "direction": d,
|
||||
"in_full": k in full_keys, "in_replay": seen is not None,
|
||||
"i_seen": -1 if seen is None else seen,
|
||||
"late": (np.nan if seen is None else seen - e),
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
|
||||
# 实盘真正会做的:首现根入场(首现==entry_idx 即准时)
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
live = r[r.in_replay & (r.i_seen < n - 2)].copy()
|
||||
live = live[np.isfinite(atr[live.i_seen.values])
|
||||
& (atr[live.i_seen.values] > 0)]
|
||||
if not live.empty:
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": live.i_seen.values,
|
||||
"direction": live.direction.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(live):
|
||||
for c in ("g", "r", "c"):
|
||||
live[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
live["atr_pct"] = (atr[live.i_seen.values]
|
||||
/ cl[live.i_seen.values])
|
||||
r = r.merge(live[["entry_idx", "direction", "g", "r", "c",
|
||||
"atr_pct"]],
|
||||
on=["entry_idx", "direction"], how="left")
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 一、准时率与额外信号 ##########")
|
||||
rows = []
|
||||
for tf, x in d.groupby("tf"):
|
||||
both = x[x.in_full & x.in_replay]
|
||||
rows.append({
|
||||
"tf": tf,
|
||||
"全量信号": int(x.in_full.sum()),
|
||||
"回放信号": int(x.in_replay.sum()),
|
||||
"召回": f"{len(both)/max(int(x.in_full.sum()),1)*100:.1f}%",
|
||||
"准时(首现==entry)": f"{(both.late == 0).mean()*100:.1f}%",
|
||||
"迟到中位": (f"{both.late[both.late > 0].median():.0f} 根"
|
||||
if (both.late > 0).any() else "—"),
|
||||
"额外信号": int((x.in_replay & ~x.in_full).sum()),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n额外信号 = 回放发得出、全量没有的。§5.41 预言它们存在"
|
||||
"(实盘 available_ts 更早 -> 信号更多更早),本表给出数量。")
|
||||
|
||||
if "g" not in d.columns:
|
||||
return
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 二、分组收益:回测口径 vs 实盘口径 ##########")
|
||||
for tf, x in d.groupby("tf"):
|
||||
y = x.dropna(subset=["g"])
|
||||
if len(y) < 30:
|
||||
continue
|
||||
print(f"\n--- {tf} ---")
|
||||
rows = []
|
||||
for nm, g in [
|
||||
("全部回放信号(=实盘会做的)", y[y.in_replay]),
|
||||
("其中 准时的", y[y.in_replay & (y.late == 0)]),
|
||||
("其中 迟到的", y[y.in_replay & (y.late > 0)]),
|
||||
("其中 额外的(全量没有)", y[y.in_replay & ~y.in_full]),
|
||||
("回测口径(全量∩回放)", y[y.in_full & y.in_replay]),
|
||||
]:
|
||||
if len(g) >= 30:
|
||||
rows.append({"分组": nm, **perf(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:若「全部回放信号」的 PF 不低于「回测口径」,说明 B4 的时点"
|
||||
"是干净的,\n且 §5.41 说的『回测偏保守』成立 —— 实盘拿到的反而更多。"
|
||||
"\n若额外信号那组显著更差,那就是回测没统计到的隐性成本。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--warm", type=int, default=20_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[B4 因果回放] {len(syms)} 币 × {args.steps} 根逐根重放\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.tf, args.rows, args.warm,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,257 @@
|
||||
"""二类买卖点:它只是一类的延迟版,还是一类里被确认过的那个子集?
|
||||
|
||||
用户提出的悖论:二类按定义是「一买之后回调、再继续趋势」,可一类已经证否了,
|
||||
二类凭什么会好?
|
||||
|
||||
代码上悖论成立——`find_all_bsp` 里 B2 确实被 B1 门控(`if first_bsp_bi_div`)。
|
||||
但 B2 比 B1 多要求两件事:价格**确实反弹了**(bounce_bi 向上),
|
||||
且回踩**守住了** B1 的低点(`second_bsp_bi.end_klc.low > leave_bi.end_klc.low`)。
|
||||
**这两条正是「那个底是真的」的事后确认。**
|
||||
|
||||
而 §3.397 的诊断恰恰是:一类只有 30% 落在真反转上,那 30% 的 PF 是 0.70~0.83,
|
||||
另外 70% 是 0.08。所以「按确认筛掉假底」正是一类缺的东西。
|
||||
|
||||
于是悖论变成一个可测的问题:
|
||||
|
||||
B2 命中线段顶点的比例,是否显著高于 B1 的 30%?
|
||||
|
||||
是 -> B2 是 B1 的**已验证子集**,悖论解除,值得继续查
|
||||
否 -> B2 只是 B1 的延迟版,用户的悖论成立,直接关掉
|
||||
|
||||
同时量三件与一类可比的东西(口径完全对齐,才能横向比):
|
||||
几何 入场价到结构止损位有多远(一类是 2.90 ATR,止损落在结构内侧 88.6%)
|
||||
波动 atr_z(一类 1.22 偏高,二类 0.94 偏低,画像相反)
|
||||
反手 一类反手在全量口径上曾看似很好,二类是否也有这个现象
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step64_b2.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
BASE_WIN, TOL = 200, 2
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
op = cdf["open"].to_numpy(float)
|
||||
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
|
||||
.median().shift(1).to_numpy())
|
||||
|
||||
seg_bot, seg_top = [], []
|
||||
for sg in getattr(chan, "seg_list", []) or []:
|
||||
if sg.end_time is None:
|
||||
continue
|
||||
i = to_i(sg.end_time)
|
||||
if 0 <= i < n:
|
||||
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN
|
||||
else seg_top).append(i)
|
||||
if not seg_bot or not seg_top:
|
||||
return None
|
||||
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
|
||||
|
||||
def near(i: int, d: int) -> bool:
|
||||
a = truth[d]
|
||||
k = int(np.searchsorted(a, i))
|
||||
return any(0 <= j < len(a) and abs(int(a[j]) - i) <= TOL
|
||||
for j in (k - 1, k))
|
||||
|
||||
# 一类按中枢建索引,好给二类找到它自己那个一类的低点 ——
|
||||
# 二类的天然止损位是**一类的极值**,不是它自己的极值
|
||||
one_ext: dict[int, float] = {}
|
||||
for b in bsp:
|
||||
if b.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1) and b.zs:
|
||||
d = 1 if b.type == Chan_BSP_TYPE.B1 else -1
|
||||
one_ext[id(b.zs)] = float(b.klc.low if d == 1 else b.klc.high)
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1)}
|
||||
rec = []
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
a = atr[i_ext]
|
||||
if not np.isfinite(a) or a <= 0 or not np.isfinite(base[i_ext]):
|
||||
continue
|
||||
own = float(b.klc.low if d == 1 else b.klc.high)
|
||||
# 二类的结构止损用一类的极值(回踩不破的就是那个点);
|
||||
# 一类用自己的极值。这样两者的「入场离结构位多远」才可比
|
||||
struct = own
|
||||
if name in ("B2", "S2") and b.zs is not None:
|
||||
struct = one_ext.get(id(b.zs), own)
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": name, "dir": d,
|
||||
"i_ext": i_ext, "i_sure": i_sure,
|
||||
"lag_bars": i_sure - i_ext,
|
||||
"hit": near(i_ext, d),
|
||||
"atr_z": a / base[i_ext],
|
||||
"own_ext": own, "struct_px": struct,
|
||||
"entry_px": op[min(i_sure + 1, n - 1)],
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
r = r[(r.i_sure < n - 2) & np.isfinite(atr[r.i_sure.values])
|
||||
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
r["atr_at_entry"] = atr[r.i_sure.values]
|
||||
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
|
||||
r["to_struct"] = ((r.entry_px - r.struct_px) * r.dir
|
||||
/ r.atr_at_entry)
|
||||
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
for sfx, sgn in (("", 1), ("f_", -1)):
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.i_sure.values,
|
||||
"direction": sgn * r.dir.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c", "b"):
|
||||
r[sfx + c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame, pre: str = "") -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
gg, rr, cc = g[pre + "g"].values, g[pre + "r"].values, g[pre + "c"].values
|
||||
net = gg - fee_of(rr, cc)
|
||||
gR = gg / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(rr, cc)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 96)
|
||||
print(f"########## {tf} ##########")
|
||||
|
||||
print("\n【一】悖论的判据:二类命中线段顶点的比例是否高于一类")
|
||||
rows = []
|
||||
for t in ["B1", "S1", "B2", "S2"]:
|
||||
g = x[x.type == t]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
rows.append({
|
||||
"类型": t, "样本": len(g),
|
||||
"命中线段顶点": f"{g.hit.mean()*100:.1f}%",
|
||||
"atr_z中位": round(g.atr_z.median(), 3),
|
||||
"滞后中位": int(g.lag_bars.median()),
|
||||
"入场到结构位(ATR)": round(g.to_struct.median(), 2),
|
||||
"止损2ATR落在结构内侧": f"{(g.to_struct > 2).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n 「入场到结构位」是入场价离天然止损位多少个 ATR。二类的结构位取"
|
||||
"\n 它那个一类的极值(回踩不破的就是那点)。>2 表示 2ATR 的止损挂在"
|
||||
"\n 结构位以内,价格不用回踩到前低就出局 —— 一类实测 88.6%。")
|
||||
|
||||
print("\n【二】按原方向做(抄底/摸顶)")
|
||||
print(pd.DataFrame([{"类型": t, **perf(g)}
|
||||
for t, g in x.groupby("type")
|
||||
if len(g) >= 30]).to_string(index=False))
|
||||
|
||||
print("\n【三】反手做")
|
||||
print(pd.DataFrame([{"类型": t, **perf(g, "f_")}
|
||||
for t, g in x.groupby("type")
|
||||
if len(g) >= 30]).to_string(index=False))
|
||||
|
||||
print("\n【四】二类拆命中/未命中 —— 一类的对应数字是 0.08 vs 0.70")
|
||||
two = x[x.type.isin(["B2", "S2"])]
|
||||
if len(two) >= 60:
|
||||
print(pd.DataFrame([
|
||||
{"命中线段顶点": "是" if k else "否", **perf(g)}
|
||||
for k, g in two.groupby(two.hit) if len(g) >= 20
|
||||
]).to_string(index=False))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
ap.add_argument("--workers", type=int, default=2)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""改识别规则之前,先量机会本身装不装得下确认成本。
|
||||
|
||||
用户问:改 B1/B2 的识别规则行不行?
|
||||
|
||||
「改识别规则」有两种含义,第一种已经被测过上限:
|
||||
(a) 换规则**挑**出更好的 B1 —— step60 用未来函数只留极值落在真线段底 ±2 根内
|
||||
的信号,这是任何识别规则的理论最好情况,PF 仅 0.70~0.83。这一类改法封死。
|
||||
|
||||
但 0.83 这个上限本身是**结果**,背后是一个从没直接测过的量:
|
||||
|
||||
一段线段从真底走到真顶,一共有几个 ATR?而等笔确认要花掉 2.9 个。
|
||||
|
||||
这决定了 (b) 类改法(改**入场时点/构造**,而非改选样)有没有空间:
|
||||
|
||||
线段幅度 4 ATR -> 进场吃掉 2.9,剩 1.1 去扛 2 ATR 止损,**任何规则都救不活**
|
||||
线段幅度 12 ATR -> 2.9 只是小费,值得继续改规则
|
||||
|
||||
注意这测的是**市场**,不是我们的检测器:标准答案直接取 `seg_list` 的真实端点,
|
||||
完全不经过 `find_all_bsp`。所以结论对「换任何一套识别规则」都成立。
|
||||
|
||||
而且这个比值大概率**随级别变化**(ATR 与线段幅度未必同比例缩放),所以跑
|
||||
5m/15m/1h/4h。真正的产出不是「改不改规则」,而是**「在哪个级别上改才有意义」**。
|
||||
|
||||
预算恒等式(每根线段一行):
|
||||
可捕获 = 线段幅度 − 确认成本 需要 > 止损 才有正期望的可能
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step65_room.feather"
|
||||
SL_ATR = 2.0
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 3_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
segs = [s for s in (getattr(chan, "seg_list", []) or [])
|
||||
if s.end_time is not None and s.start_time is not None]
|
||||
if len(segs) < 20:
|
||||
return None
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
hi, lo = cdf["high"].to_numpy(float), cdf["low"].to_numpy(float)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
rec = []
|
||||
for sg in segs:
|
||||
i0, i1 = to_i(sg.start_time), to_i(sg.end_time)
|
||||
if not (0 <= i0 < i1 < n) or i1 - i0 < 2:
|
||||
continue
|
||||
a = atr[i0]
|
||||
if not np.isfinite(a) or a <= 0:
|
||||
continue
|
||||
up = sg.dir != Chan_SEG_DIR.DOWN
|
||||
# 从线段起点(上一段的真实反转点)到终点的幅度
|
||||
span = ((hi[i0:i1 + 1].max() - lo[i0]) if up
|
||||
else (hi[i0] - lo[i0:i1 + 1].min()))
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "dir": 1 if up else -1,
|
||||
"bars": i1 - i0,
|
||||
"span_atr": span / a,
|
||||
"atr_pct": a / float(cdf["close"].to_numpy(float)[i0]),
|
||||
})
|
||||
return pd.DataFrame(rec) if rec else None
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
TFO = {"5m": 0, "15m": 1, "1h": 2, "4h": 3}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame, cost: float) -> None:
|
||||
print("\n" + "=" * 96)
|
||||
print(f"【一】线段幅度 vs 确认成本(成本按一类实测的 {cost} ATR 计)")
|
||||
print("=" * 96)
|
||||
rows = []
|
||||
for tf, x in sorted(d.groupby("tf"), key=lambda kv: TFO.get(kv[0], 9)):
|
||||
q = x.span_atr.quantile([.25, .5, .75]).values
|
||||
room = x.span_atr - cost
|
||||
rows.append({
|
||||
"级别": tf, "线段数": len(x),
|
||||
"幅度Q1": round(q[0], 1), "幅度中位": round(q[1], 1),
|
||||
"幅度Q3": round(q[2], 1),
|
||||
"确认后剩余(中位)": round(q[1] - cost, 1),
|
||||
f"剩余>止损{SL_ATR}": f"{(room > SL_ATR).mean()*100:.0f}%",
|
||||
"剩余/止损": round((q[1] - cost) / SL_ATR, 2),
|
||||
"中位时长(根)": int(x.bars.median()),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print(f"""
|
||||
「剩余/止损」是这张表的结论行:真底进场、扣掉确认成本后,还剩几倍止损的空间。
|
||||
< 1 机会装不下确认成本,**换任何识别规则都没用**
|
||||
1~2 勉强打平,要求选样精度极高(step60 实测最好 30%)
|
||||
> 2 有空间,值得改规则/改入场构造""")
|
||||
|
||||
print("\n【二】把确认成本当变量:多低才够用")
|
||||
rows = []
|
||||
for tf, x in sorted(d.groupby("tf"), key=lambda kv: TFO.get(kv[0], 9)):
|
||||
r = {"级别": tf}
|
||||
for c in (0.0, 1.0, 2.0, 2.9):
|
||||
r[f"成本{c}"] = round((x.span_atr.median() - c) / SL_ATR, 2)
|
||||
rows.append(r)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n 成本 0 = 完美实时(在真底那根就进)。若连成本 0 那列都 < 2,"
|
||||
"\n 说明**不是滞后的问题,是这个级别的线段本身就太小**。")
|
||||
|
||||
print("\n【三】逐币(中位幅度 ATR),看结论是否普适")
|
||||
print(d.pivot_table(index="sym", columns="tf", values="span_atr",
|
||||
aggfunc="median").round(1)
|
||||
.reindex(columns=[t for t in TFO if t in set(d.tf)])
|
||||
.to_string())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m,1h,4h")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
ap.add_argument("--cost", type=float, default=2.9)
|
||||
ap.add_argument("--workers", type=int, default=2)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT), args.cost)
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d, args.cost)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""改识别规则到底有没有用:把天花板一次性测到顶。
|
||||
|
||||
用户问「改 B1/B2 的识别规则呢」。step65 让这个问题变得可判定,但也制造了一个
|
||||
必须解释的矛盾:
|
||||
|
||||
线段中位 11.4 ATR,进场花掉 2.9,剩 8.5 —— 是 2 ATR 止损的 4.26 倍。
|
||||
可 step60 用未来函数选出真底的那批 B1,PF 仍只有 0.70~0.83。
|
||||
|
||||
空间明明在,就是拿不到。两种解释对用户的问题给出**相反**的答案:
|
||||
|
||||
选样问题:只有 30% 的 B1 落在真线段底,那 8.5 ATR 只存在于这 30% 里,
|
||||
其余 70% 是趋势中途,后面根本没有行情 -> **改识别规则有用**
|
||||
路径问题:就算落在真底,11.4 ATR 是净幅度,路上的回撤照样打掉止损
|
||||
-> **改识别规则没用**
|
||||
|
||||
区分只需要一个从没跑过的组合:**未来函数选样 + 结构止损 + 绝对目标**。
|
||||
三样单独都失败过(step60 的 0.83 / step58 的 0.36 / 本轮 abs 的 0.36),
|
||||
组合起来没试过。它测的不是某条规则,是**所有识别规则的上界**:
|
||||
|
||||
上界 < 1.0 -> 路径问题,一类线彻底关闭,别再改规则
|
||||
上界 > 1.5 -> 选样问题,改识别规则有用,且这里就是要够到的目标
|
||||
|
||||
同时量 MFE 做交叉验证:真底那批到底跑没跑出 8.5 ATR。
|
||||
跑出来了却还是亏 -> 铁证是止损/路径
|
||||
根本没跑出来 -> 说明 11.4 ATR 那个测量传导不到这批信号上,step65 要重解释
|
||||
"""
|
||||
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))
|
||||
|
||||
SRC = HERE / "out" / "step64_b2.feather"
|
||||
MAXB = 48
|
||||
|
||||
|
||||
def mfe_mae(cdf: pd.DataFrame, idx: np.ndarray, dirs: np.ndarray,
|
||||
atr: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""入场后 MAXB 根内最远的顺向/逆向行程(ATR)。"""
|
||||
hi, lo = cdf["high"].to_numpy(float), cdf["low"].to_numpy(float)
|
||||
op = cdf["open"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
fe = np.full(len(idx), np.nan)
|
||||
ae = np.full(len(idx), np.nan)
|
||||
for k, (i, d, a) in enumerate(zip(idx, dirs, atr)):
|
||||
e = int(i) + 1
|
||||
if e >= n or not np.isfinite(a) or a <= 0:
|
||||
continue
|
||||
j = min(e + MAXB, n)
|
||||
px = op[e]
|
||||
if d == 1:
|
||||
fe[k] = (hi[e:j].max() - px) / a
|
||||
ae[k] = (px - lo[e:j].min()) / a
|
||||
else:
|
||||
fe[k] = (px - lo[e:j].min()) / a
|
||||
ae[k] = (hi[e:j].max() - px) / a
|
||||
return fe, ae
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
args = ap.parse_args()
|
||||
|
||||
from chanlun import TF_DF
|
||||
from lib.data import fetch_ohlcv
|
||||
from step58_struct_stop import simulate, stats
|
||||
|
||||
d = pd.read_feather(SRC)
|
||||
d = d[(d.tf == args.tf) & d.type.isin(["B1", "S1"])].copy()
|
||||
d["to_ext"] = (d.entry_px - d.own_ext) * d.dir / d.atr_at_entry
|
||||
print(f"[识别规则天花板] {args.tf} · 一类 {len(d)} 笔 · "
|
||||
f"其中落在真线段端点 {d.hit.sum()} 笔 ({d.hit.mean()*100:.1f}%)\n")
|
||||
|
||||
cache = {}
|
||||
for sym in sorted(d.sym.unique()):
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
|
||||
cache[sym] = TF_DF(df, 1, args.tf).dataframe
|
||||
|
||||
print("=" * 96)
|
||||
print("【一】MFE 交叉验证:真底那批到底跑没跑出 step65 说的 8.5 ATR")
|
||||
print("=" * 96)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.hit):
|
||||
fe, ae = [], []
|
||||
for sym, x in g.groupby("sym"):
|
||||
f_, a_ = mfe_mae(cache[sym], x.i_sure.values, x.dir.values,
|
||||
x.atr_at_entry.values)
|
||||
fe.append(f_)
|
||||
ae.append(a_)
|
||||
fe, ae = np.concatenate(fe), np.concatenate(ae)
|
||||
ok = np.isfinite(fe)
|
||||
rows.append({
|
||||
"落在真线段端点": "是" if k else "否", "笔数": int(ok.sum()),
|
||||
"MFE中位": round(float(np.median(fe[ok])), 2),
|
||||
"MFE≥3ATR": f"{(fe[ok] >= 3).mean()*100:.0f}%",
|
||||
"MFE≥8ATR": f"{(fe[ok] >= 8).mean()*100:.0f}%",
|
||||
"MAE中位": round(float(np.median(ae[ok])), 2),
|
||||
"MAE≥2ATR(会被现止损打掉)": f"{(ae[ok] >= 2).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
判读:若「是」那行 MFE 中位远低于 8.5,说明 step65 的线段幅度传导不到
|
||||
信号上(进场时行情已经走掉了大半);若 MFE 够大而 MAE 也大,则是路径问题。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【二】天花板:未来函数选样 + 结构止损 + 绝对目标(三者首次组合)")
|
||||
print("=" * 96)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.hit):
|
||||
for label, margin in [("固定2ATR", None), ("结构+0.5", 0.5),
|
||||
("结构+1.0", 1.0), ("结构+1.5", 1.5)]:
|
||||
gs, rs, cs, ap_, sl_ = [], [], [], [], []
|
||||
for sym, x in g.groupby("sym"):
|
||||
x = x.reset_index(drop=True)
|
||||
t = pd.DataFrame({"entry_idx": x.i_sure.values,
|
||||
"direction": x.dir.values,
|
||||
"atr_at_entry": x.atr_at_entry.values})
|
||||
sl = (np.full(len(x), 2.0) if margin is None
|
||||
else (x.to_ext.values + margin))
|
||||
res = simulate(cache[sym], t, sl, r_scale=False)
|
||||
ok = res.g.notna().values
|
||||
gs.append(res.g[ok])
|
||||
rs.append(res.r[ok])
|
||||
cs.append(res.c[ok])
|
||||
ap_.append((x.atr_at_entry.values / x.entry_px.values)[ok])
|
||||
sl_.append(sl[ok])
|
||||
rows.append({
|
||||
"落在真线段端点": "是" if k else "否", "止损": label,
|
||||
**stats(pd.concat(gs, ignore_index=True),
|
||||
pd.concat(rs, ignore_index=True),
|
||||
pd.concat(cs, ignore_index=True),
|
||||
np.concatenate(ap_), np.concatenate(sl_))})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
「是」那几行就是**任何识别规则的上界**(因为选样已经用了未来函数):
|
||||
< 1.0 路径问题,一类线关闭,改规则无解
|
||||
> 1.5 选样问题,改识别规则有用,且这就是要够到的目标""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""把 step66 的天花板换成实时可算的选样,看能兑现多少。
|
||||
|
||||
step66 测出上界:**未来函数选样 + 结构止损**在 5m 是 PF 2.29、15m 是 4.11。
|
||||
但那个选样用了线段端点(事后才知道),不能交易。本脚本把它换成 step62 那个
|
||||
**实时可算**的 `ext_run`(极值越过中枢边界几个 ATR,当根即可算),配上结构止损。
|
||||
|
||||
这是唯一同时处理两个已知约束的组合,也是 §3.3992 列出的第一个待跑实验:
|
||||
|
||||
step62 有实时过滤器(精度 28.7% -> 47.9%)但配了 2 ATR 止损 -> PF 0.54
|
||||
step58 有结构止损但没有过滤器 -> PF 0.36
|
||||
step66 两个都有,但选样是未来函数 -> PF 2.29 / 4.11
|
||||
**本脚本:实时过滤器 + 结构止损** -> ?
|
||||
|
||||
判读(对照 step66 的上界):
|
||||
> 1.3 兑现了相当部分,一类线值得继续,下一步做因果回放(sure_time 仍是未来函数)
|
||||
~ 1.0 过滤器精度不够,需要更好的实时特征
|
||||
< 0.8 实时特征抓不到那 28.7%,天花板兑现不了,一类线仍关闭
|
||||
|
||||
⚠️ 即使 > 1.3 也**不能算数**:入场仍在 `sure_time` 上,而 §3.396 证明实盘回放
|
||||
还要再晚 15 根。这一步只决定「值不值得再花一次因果回放的机器时间」。
|
||||
"""
|
||||
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))
|
||||
|
||||
F62 = HERE / "out" / "step62_trend_end.feather"
|
||||
F64 = HERE / "out" / "step64_b2.feather"
|
||||
KEY = ["sym", "tf", "type", "i_ext", "i_sure"]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
args = ap.parse_args()
|
||||
|
||||
from chanlun import TF_DF
|
||||
from lib.data import fetch_ohlcv
|
||||
from step58_struct_stop import simulate, stats
|
||||
|
||||
a = pd.read_feather(F62)[KEY + ["ext_run", "div", "ladder_n"]]
|
||||
b = pd.read_feather(F64)[KEY + ["entry_px", "own_ext",
|
||||
"atr_at_entry", "hit", "dir"]]
|
||||
d = a.merge(b, on=KEY, how="inner")
|
||||
d = d[d.tf == args.tf].reset_index(drop=True)
|
||||
d["to_ext"] = (d.entry_px - d.own_ext) * d.dir / d.atr_at_entry
|
||||
|
||||
print(f"[实时过滤器 + 结构止损] {args.tf} · 一类 {len(d)} 笔 · "
|
||||
f"真线段端点占比 {d.hit.mean()*100:.1f}%")
|
||||
print(f"step66 上界(同止损、但用未来函数选样):"
|
||||
f"{'PF 2.29' if args.tf == '5m' else 'PF 4.11'}\n")
|
||||
|
||||
cache = {}
|
||||
for sym in sorted(d.sym.unique()):
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
|
||||
cache[sym] = TF_DF(df, 1, args.tf).dataframe
|
||||
|
||||
def run(x: pd.DataFrame, margin: float | None) -> dict:
|
||||
gs, rs, cs, ap_, sl_ = [], [], [], [], []
|
||||
for sym, g in x.groupby("sym"):
|
||||
g = g.reset_index(drop=True)
|
||||
t = pd.DataFrame({"entry_idx": g.i_sure.values,
|
||||
"direction": g.dir.values,
|
||||
"atr_at_entry": g.atr_at_entry.values})
|
||||
sl = (np.full(len(g), 2.0) if margin is None
|
||||
else (g.to_ext.values + margin))
|
||||
res = simulate(cache[sym], t, sl, r_scale=False)
|
||||
ok = res.g.notna().values
|
||||
gs.append(res.g[ok])
|
||||
rs.append(res.r[ok])
|
||||
cs.append(res.c[ok])
|
||||
ap_.append((g.atr_at_entry.values / g.entry_px.values)[ok])
|
||||
sl_.append(sl[ok])
|
||||
return stats(pd.concat(gs, ignore_index=True),
|
||||
pd.concat(rs, ignore_index=True),
|
||||
pd.concat(cs, ignore_index=True),
|
||||
np.concatenate(ap_), np.concatenate(sl_))
|
||||
|
||||
print("=" * 96)
|
||||
print("【一】ext_run 分档 × 止损口径 —— 过滤器的效果依赖止损吗")
|
||||
print("=" * 96)
|
||||
q = d.ext_run.quantile([.25, .5, .75]).values
|
||||
lab = ["Q1最短", "Q2", "Q3", "Q4最延伸"]
|
||||
d["bucket"] = pd.cut(d.ext_run, [-np.inf, *q, np.inf], labels=lab)
|
||||
rows = []
|
||||
for bk, g in d.groupby("bucket", observed=True):
|
||||
for name, m in [("固定2ATR", None), ("结构+1.0", 1.0)]:
|
||||
rows.append({"ext_run档": bk, "止损": name, "真端点占比":
|
||||
f"{g.hit.mean()*100:.0f}%", **run(g, m)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
§3.399 判过「过滤器符号随入场时点翻转 -> 拟合」。这里换的是**止损**而非入场,
|
||||
若 Q4 在两种止损下都最好,说明 ext_run 是真信号;若又翻转,则仍是拟合。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【二】实时可交易组合 vs step66 上界")
|
||||
print("=" * 96)
|
||||
rows = [{"选样": "全体(无过滤)", "笔数": len(d), **run(d, 1.0)}]
|
||||
for p in (50, 75):
|
||||
thr = np.percentile(d.ext_run, p)
|
||||
g = d[d.ext_run >= thr]
|
||||
rows.append({"选样": f"ext_run ≥ P{p}(实时)", "笔数": len(g),
|
||||
**run(g, 1.0)})
|
||||
g = d[(d.ext_run >= np.percentile(d.ext_run, 75))
|
||||
& (d["div"] >= d["div"].median())]
|
||||
rows.append({"选样": "ext_run≥P75 且 div≥中位(实时)",
|
||||
"笔数": len(g), **run(g, 1.0)})
|
||||
gh = d[d.hit]
|
||||
rows.append({"选样": "★真线段端点(未来函数=上界)", "笔数": len(gh),
|
||||
**run(gh, 1.0)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
判读:实时行若接近 ★ 那行,说明 ext_run 抓到了同一批信号,一类线值得继续;
|
||||
若仍贴近「全体」,说明实时特征抓不到那 28.7%,天花板兑现不了。
|
||||
⚠️ 即便好也不算数——入场仍在 sure_time 上,必须再过一次因果回放。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【三】数字对不上,追一下:ext_run 挑出的真端点,质量还一样吗")
|
||||
print("=" * 96)
|
||||
print("Q4 的真端点浓度 44%(基线 28.7%),若真端点都值 PF 2.29,"
|
||||
"Q4 不该只有 0.51。\n拆开看 ext_run 在真端点**内部**是帮忙还是帮倒忙——"
|
||||
"这决定天花板是否可学:")
|
||||
rows = []
|
||||
for k, g in d.groupby(d.hit):
|
||||
for bk, gg in g.groupby("bucket", observed=True):
|
||||
if len(gg) < 25:
|
||||
continue
|
||||
rows.append({"真端点": "是" if k else "否", "ext_run档": bk,
|
||||
"笔数": len(gg), **run(gg, 1.0)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
「是」组内若 Q4 明显低于 Q1 -> ext_run 在真端点里**反向选样**,
|
||||
它提高浓度的同时挑走了最差的那些,两个效应抵消,这解释了 0.51 vs 2.29。
|
||||
若「是」组内各档持平 -> 浓度提升是真的,缺的只是更强的实时特征。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【四】那需要多高的实时精度才能翻正")
|
||||
print("=" * 96)
|
||||
print("【三】显示只有「是不是真端点」这一个变量在起作用(组内各档持平)。"
|
||||
"\n于是 PF 只是两组按精度 p 的混合,可以直接解出盈亏平衡精度:")
|
||||
net = {}
|
||||
for k, g in d.groupby(d.hit):
|
||||
ns = []
|
||||
for sym, x in g.groupby("sym"):
|
||||
x = x.reset_index(drop=True)
|
||||
t = pd.DataFrame({"entry_idx": x.i_sure.values,
|
||||
"direction": x.dir.values,
|
||||
"atr_at_entry": x.atr_at_entry.values})
|
||||
res = simulate(cache[sym], t, x.to_ext.values + 1.0,
|
||||
r_scale=False)
|
||||
ok = res.g.notna().values
|
||||
from lib.exit_model import fee_of
|
||||
ns.append(res.g[ok].values
|
||||
- fee_of(res.r[ok].values, res.c[ok].values))
|
||||
net[bool(k)] = np.concatenate(ns)
|
||||
|
||||
def mix_pf(p: float) -> float:
|
||||
"""精度 p 时的 PF:两组按 p 加权(组内分布不变,只变权重)。"""
|
||||
h, m = net[True], net[False]
|
||||
w = (p * h[h > 0].sum() / len(h)
|
||||
+ (1 - p) * m[m > 0].sum() / len(m))
|
||||
l = (p * -h[h <= 0].sum() / len(h)
|
||||
+ (1 - p) * -m[m <= 0].sum() / len(m))
|
||||
return w / l if l > 0 else np.inf
|
||||
|
||||
grid = [0.287, 0.35, 0.44, 0.5, 0.6, 0.7, 0.8, 1.0]
|
||||
print(pd.DataFrame([{
|
||||
"实时精度": f"{p*100:.1f}%", "PF": round(mix_pf(p), 2),
|
||||
"备注": {0.287: "← 当前基线", 0.44: "← ext_run Q4 已达到",
|
||||
1.0: "← step66 上界"}.get(p, "")} for p in grid]
|
||||
).to_string(index=False))
|
||||
lo, hi = 0.287, 1.0
|
||||
if mix_pf(hi) > 1 > mix_pf(lo):
|
||||
for _ in range(40):
|
||||
mid = (lo + hi) / 2
|
||||
lo, hi = (mid, hi) if mix_pf(mid) < 1 else (lo, mid)
|
||||
print(f"\n **盈亏平衡精度 ≈ {(lo+hi)/2*100:.1f}%** "
|
||||
f"(当前 28.7%,ext_run Q4 已到 44.0%)")
|
||||
print(" 这就是「改识别规则」要够到的具体门槛,且它只是毛平衡;"
|
||||
"\n 还要再扣 §3.396 的因果滞后(实盘比 sure_time 再晚 15 根)。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,265 @@
|
||||
"""按**实盘口径**重放 B4,并测三道过滤能否刷掉那批亏钱的额外信号。
|
||||
|
||||
step63 的结论是一好一坏:
|
||||
好 时点干净 —— 100% 召回、100% 准时、零滞后(一类是 0% 准时、+15 根)
|
||||
坏 回放多出 592 个全量口径没有的信号,PF 0.46 / t −5.03,混合后 0.64 < 1
|
||||
|
||||
但 step63 有两个口径问题,本脚本一并修掉:
|
||||
|
||||
① 窗口不对。回测用全量 45000 根一次算完,step63 用 2 万涨到 4 万根的增长窗口,
|
||||
**而实盘用 2001 根滚动窗口、每 500 根 init_stream 拉回**(`shadow_signal.py`
|
||||
的 MAX_GROW)。三种口径的中枢结构都不一样。这也是 HANDOFF 里挂着的
|
||||
「回测用全量历史建中枢、实盘用 2000 根窗口」那条待办。
|
||||
顺带:窗口封顶后单步成本恒定,不再是 step63 那个平方级(143ms@2万根 ->
|
||||
292ms@4万根),所以本脚本快得多。
|
||||
|
||||
② 没测过滤。step63 跑的是裸信号,而实盘有三道滤网。ATR 门控已单独测过——
|
||||
它刷掉 7.9% 的额外信号却刷掉 10.7% 的好信号,PF 纹丝不动。剩下两道要测。
|
||||
|
||||
**一处刻意的简化,方向是保守的**:大级别分型时间线用全量历史算(真实盘的 HTF
|
||||
也会重画)。这等于**给同向过滤器开了未来函数的后门**。若连这样都刷不掉额外信号,
|
||||
结论只会更强。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step68_live_window.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
WIN, MAX_GROW, GATE_BP = 2001, 500, 8.0
|
||||
|
||||
|
||||
def _ladder(zones: pd.DataFrame) -> pd.DataFrame:
|
||||
z = zones.copy()
|
||||
pg, pdn = z["zg"].shift(), z["zd"].shift()
|
||||
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
|
||||
z["zone_i"] = np.arange(len(z))
|
||||
return z
|
||||
|
||||
|
||||
def replay(sym: str, ltf: str, htf: str, rows: int,
|
||||
steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.fx_signal import extract_fx_signals, signals_to_frame
|
||||
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
|
||||
if df is None or len(df) < WIN + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, ltf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zs_full = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
if not zs_full:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zs_full, cdf))
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
|
||||
# 大级别分型时间线:全量算(见模块 docstring 的「刻意简化」)
|
||||
dh = fetch_ohlcv(f"{sym}/USDT:USDT", htf, rows)
|
||||
tl = None
|
||||
if dh is not None and len(dh) > 500:
|
||||
ch = TF_DF(dh, 1, htf)
|
||||
tl = htf_fx_timeline(
|
||||
signals_to_frame(extract_fx_signals(ch, ch.dataframe)),
|
||||
ch.dataframe)
|
||||
|
||||
rec: dict[tuple, dict] = {}
|
||||
chan = None
|
||||
anchor = 0
|
||||
for i in range(WIN, len(df)):
|
||||
# 实盘的窗口纪律:2001 根起,长过 MAX_GROW 就 init_stream 拉回
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, ltf)
|
||||
chan.init_stream(w, 1, ltf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = _ladder(zones_from_zs_list(zl, sub))
|
||||
if z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last] # 实盘只做当根
|
||||
if s.empty:
|
||||
continue
|
||||
if "zone_i" in s.columns:
|
||||
s = s.merge(z[["zone_i", "z_above", "z_below"]],
|
||||
on="zone_i", how="left")
|
||||
if tl is not None:
|
||||
s = attach_htf_context(s, sub, tl, "h1")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (i, int(r.direction))
|
||||
if k in rec:
|
||||
continue
|
||||
push = getattr(r, "z_above" if r.direction == 1
|
||||
else "z_below", None)
|
||||
ag = getattr(r, "h1_agree", 0)
|
||||
rec[k] = {
|
||||
"sym": sym, "entry_idx": i, "direction": int(r.direction),
|
||||
"in_full": (i, int(r.direction)) in full_keys,
|
||||
"ladder_ok": int(bool(pd.notna(push) and bool(push))),
|
||||
"h1_agree": int(ag) if pd.notna(ag) else 0,
|
||||
}
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(list(rec.values()))
|
||||
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
r = r[(r.entry_idx < len(cdf) - 2)
|
||||
& np.isfinite(atr[r.entry_idx.values])
|
||||
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.entry_idx.values,
|
||||
"direction": r.direction.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c"):
|
||||
r[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
|
||||
r["gate_ok"] = (r.atr_pct * 1e4 >= GATE_BP).astype(int)
|
||||
r["pass_all"] = ((r.h1_agree == 1) & (r.ladder_ok == 1)
|
||||
& (r.gate_ok == 1)).astype(int)
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict | None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
if len(g) < 20:
|
||||
return None
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
print("\n" + "=" * 92)
|
||||
print("【一】实盘窗口下还有多少额外信号")
|
||||
print("=" * 92)
|
||||
print(f"回放信号 {len(d)} · 其中全量口径也有 {int(d.in_full.sum())} · "
|
||||
f"**额外 {int((~d.in_full).sum())}**")
|
||||
print(f"(step63 的增长窗口口径:197 / 592)")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【二】三道过滤能不能刷掉额外信号 —— 这决定实盘是否在亏钱")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for nm, m in [("① 无过滤", None), ("② 仅同向", d.h1_agree == 1),
|
||||
("③ 仅阶梯", d.ladder_ok == 1),
|
||||
("④ 仅ATR门控", d.gate_ok == 1),
|
||||
("⑤ 三道全开(实盘口径)", d.pass_all == 1)]:
|
||||
x = d if m is None else d[m]
|
||||
if x.empty:
|
||||
continue
|
||||
ex, bt = x[~x.in_full], x[x.in_full]
|
||||
row = {"过滤": nm, "留下": len(x),
|
||||
"额外占比": f"{(~x.in_full).mean()*100:.0f}%"}
|
||||
for lab, g in [("全部", x), ("额外", ex), ("回测口径", bt)]:
|
||||
s = perf(g)
|
||||
row[f"{lab}PF"] = "—" if s is None else s["PF"]
|
||||
row[f"{lab}n"] = len(g)
|
||||
rows.append(row)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【三】实盘口径(三道全开)的完整表现")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for nm, g in [("实盘会做的全部", d[d.pass_all == 1]),
|
||||
(" 其中额外的", d[(d.pass_all == 1) & ~d.in_full]),
|
||||
(" 其中回测也有的", d[(d.pass_all == 1) & d.in_full])]:
|
||||
s = perf(g)
|
||||
if s:
|
||||
rows.append({"分组": nm, **s})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
判读:「实盘会做的全部」PF > 1 -> 实盘安全,额外信号被滤网挡住了
|
||||
PF < 1 -> **实盘在做一批回测里不存在、且亏钱的信号**,要立刻处理""")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--ltf", default="5m")
|
||||
ap.add_argument("--htf", default="30m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[实盘口径回放] {len(syms)} 币 × {args.steps} 根 · "
|
||||
f"{WIN} 根滚动窗口 / 每 {MAX_GROW} 根重建\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.ltf, args.htf, args.rows,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""那 72~77% 的「额外信号」到底怎么来的:中枢重画,还是扫描窗口错位?
|
||||
|
||||
我在 §3.3994 里把它归因为「中枢重画」,**这个归因没验证过,而且与已有结论矛盾**
|
||||
(step39 的假阳性 0%、`verify_window_sens` 的窗口 +200/+500/+1000 逐字段一致)。
|
||||
用户指出中枢不重画,代码注释也支持他:
|
||||
|
||||
# -1 = 最后一笔(历史默认)。中枢每吸收一根K线,最后一笔就可能后移,
|
||||
# 于是 available_ts 跟着漂——这是右边缘重画的根因
|
||||
—— chanlun/analysis/fast_bsp.py:47
|
||||
|
||||
漂的不是中枢**边界**(zg/zd),是它的**可用时刻**。而 `find_fast_bsp3` 只从
|
||||
`available_ts` 往后扫 `scan=200` 根。两种口径的窗口因此错位:
|
||||
|
||||
实时 中枢没吸收完,available_ts 偏早 -> 窗口开得早
|
||||
全量 中枢吸收完了,available_ts 偏晚(§5.41 实测中位晚 62 分钟)-> 窗口开得晚
|
||||
|
||||
落在「实时窗口内、全量窗口外」的信号,全量根本没扫到那个时段,于是显示为「额外」。
|
||||
|
||||
两种机制的修法完全不同,所以必须分清:
|
||||
|
||||
边界重画 结构本身不稳,只能用滞后换稳定性,代价大
|
||||
窗口错位 中枢是同一个真中枢,信号也是真信号,只是**开得太早、确认不足**
|
||||
—— 这正好解释它们为什么亏(PF 0.26~0.36),且修法是调 available_ts
|
||||
|
||||
判据:逐个额外信号,去全量中枢表里按 (zg, zd) 找它的中枢。
|
||||
找得到且边界一致 -> 窗口错位(用户是对的,我的归因错了)
|
||||
找不到或边界不同 -> 边界重画(我的归因成立)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step69_mech.feather"
|
||||
WIN, MAX_GROW, SCAN = 2001, 500, 200
|
||||
TOL = 1e-6
|
||||
|
||||
|
||||
def replay(sym: str, ltf: str, rows: int, steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
|
||||
if df is None or len(df) < WIN + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, ltf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zf = zones_from_zs_list(full.cal_bi_zs_list_pure(full.bi_list), cdf)
|
||||
if zf is None or zf.empty:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zf)
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
fzg = zf["zg"].to_numpy(float)
|
||||
fzd = zf["zd"].to_numpy(float)
|
||||
fav = zf["available_ts"].to_numpy()
|
||||
ts_all = cdf["timestamp"].to_numpy()
|
||||
|
||||
rec: dict[tuple, dict] = {}
|
||||
chan, anchor = None, 0
|
||||
for i in range(WIN, len(df)):
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, ltf)
|
||||
chan.init_stream(w, 1, ltf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z is None or z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last]
|
||||
if s.empty or "zone_i" not in s.columns:
|
||||
continue
|
||||
# find_fast_bsp3 的输出自带 zg/zd,直接 merge 会加后缀,
|
||||
# 所以中枢侧的列全部改名再接
|
||||
zc = z[["zg", "zd", "available_ts"]].rename(columns={
|
||||
"zg": "z_zg", "zd": "z_zd", "available_ts": "z_av"})
|
||||
zc["zone_i"] = np.arange(len(z))
|
||||
s = s.drop(columns=[c for c in ("z_zg", "z_zd", "z_av")
|
||||
if c in s.columns])
|
||||
s = s.merge(zc, on="zone_i", how="left")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (i, int(r.direction))
|
||||
if k in rec:
|
||||
continue
|
||||
# 该中枢在全量表里是否存在(按边界匹配,边界是不该漂的量)
|
||||
zg_, zd_ = float(r.z_zg), float(r.z_zd)
|
||||
m = (np.abs(fzg - zg_) <= TOL * max(1.0, abs(zg_))) \
|
||||
& (np.abs(fzd - zd_) <= TOL * max(1.0, abs(zd_)))
|
||||
j = int(np.argmax(m)) if m.any() else -1
|
||||
rec[k] = {
|
||||
"sym": sym, "entry_idx": i, "direction": int(r.direction),
|
||||
"in_full": k in full_keys,
|
||||
"zone_found": bool(m.any()),
|
||||
"z_zg": zg_, "z_zd": zd_,
|
||||
"avail_rt": int(r.z_av),
|
||||
"avail_full": int(fav[j]) if j >= 0 else -1,
|
||||
"ts_entry": int(ts_all[i]) if i < len(ts_all) else -1,
|
||||
}
|
||||
return pd.DataFrame(list(rec.values())) if rec else None
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame, ltf: str) -> None:
|
||||
ex = d[~d.in_full]
|
||||
print("\n" + "=" * 92)
|
||||
print("【一】判据:额外信号所在的中枢,在全量表里找得到吗")
|
||||
print("=" * 92)
|
||||
print(f"回放信号 {len(d)} · 额外 {len(ex)}")
|
||||
print(f"**额外信号中,其中枢按 (zg,zd) 在全量表里找得到的:"
|
||||
f"{ex.zone_found.mean()*100:.1f}%**")
|
||||
print(f"(对照:非额外信号 {d[d.in_full].zone_found.mean()*100:.1f}%)")
|
||||
print("""
|
||||
≈100% -> 中枢边界没变,是**扫描窗口错位**,用户对、我的「重画」归因错
|
||||
明显偏低 -> 中枢确实消失或改边界,「重画」成立""")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【一b】数量级对账:中枢层面只有 6.5% 被改,信号层面却 74% 是额外的")
|
||||
print("=" * 92)
|
||||
print("§5.41 的 A/B 实测:`bis[-1]` 口径下确认时刻被改 6.5%、中枢消失 9.0%。")
|
||||
print("若信号层面的 74% 成立,必然有放大机制。怀疑是 `max_per_zone=1`:")
|
||||
print(" 每个中枢只返回**第一个**入场点,而扫描起点随 available_ts 棘轮后移,")
|
||||
print(" 越过旧入场点后,同一中枢会重新产出一个「第一个」——全量只用最终值,")
|
||||
print(" 所以每中枢至多一个信号,实时却能反复触发。")
|
||||
for nm, x in [("额外信号", ex), ("非额外信号", d[d.in_full])]:
|
||||
if x.empty:
|
||||
continue
|
||||
nz = x.groupby(["sym", "z_zg", "z_zd"]).size() \
|
||||
if "z_zg" in x.columns else None
|
||||
if nz is None:
|
||||
print(" (缺 z_zg/z_zd 列,跳过)")
|
||||
break
|
||||
print(f"\n{nm}:{len(x)} 个信号,落在 {len(nz)} 个不同中枢上 "
|
||||
f"-> 每中枢 {len(x)/len(nz):.2f} 次")
|
||||
print(f" 同一中枢触发次数分布 中位 {nz.median():.0f} "
|
||||
f"P90 {nz.quantile(.9):.0f} 最大 {nz.max()}")
|
||||
print("""
|
||||
若「额外信号」的每中枢次数显著 > 1 而「非额外」≈ 1,放大机制坐实:
|
||||
6.5% 的中枢改动通过棘轮重扫,放大成信号层面的几百个。""")
|
||||
|
||||
g = ex[ex.zone_found & (ex.avail_full > 0)].copy()
|
||||
if g.empty:
|
||||
return
|
||||
bar_ms = {"1m": 60_000, "5m": 300_000, "15m": 900_000}.get(ltf, 300_000)
|
||||
g["drift_bars"] = (g.avail_full - g.avail_rt) / bar_ms
|
||||
g["from_rt"] = (g.ts_entry - g.avail_rt) / bar_ms
|
||||
g["from_full"] = (g.ts_entry - g.avail_full) / bar_ms
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【二】available_ts 漂了多少,以及入场落在谁的扫描窗口里")
|
||||
print("=" * 92)
|
||||
print(f"avail 漂移(全量 − 实时,根) 中位 {g.drift_bars.median():.0f} "
|
||||
f"P25 {g.drift_bars.quantile(.25):.0f} "
|
||||
f"P75 {g.drift_bars.quantile(.75):.0f}")
|
||||
print(f" §5.41 记的是中位晚 62 分钟,本表 {ltf} 下即 "
|
||||
f"{62*60_000/bar_ms:.0f} 根,可交叉验证")
|
||||
print(f"\n入场距实时 avail(根) 中位 {g.from_rt.median():.0f} "
|
||||
f"(应落在 0~{SCAN} 内,否则实时也扫不到)")
|
||||
print(f"入场距全量 avail(根) 中位 {g.from_full.median():.0f}")
|
||||
out = ((g.from_full < 0) | (g.from_full > SCAN)).mean()
|
||||
print(f"\n**入场落在全量扫描窗口 [0,{SCAN}] 之外的比例:{out*100:.1f}%**")
|
||||
print("""
|
||||
这是机制的直接证据:比例高 -> 全量根本没扫到那个时段,所以「没有」这个信号,
|
||||
与中枢是否重画无关。其中 from_full < 0 表示入场早于全量的可用时刻——
|
||||
即**实时抢跑了**,中枢还没吸收完就下单。""")
|
||||
early = (g.from_full < 0).mean()
|
||||
print(f" 其中抢跑(早于全量 avail):{early*100:.1f}%")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--ltf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT), args.ltf)
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[机制判定] {len(syms)} 币 × {args.steps} 根 · {args.ltf}\n",
|
||||
flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex_:
|
||||
fut = {ex_.submit(replay, s, args.ltf, args.rows, args.steps): s
|
||||
for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d, args.ltf)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,280 @@
|
||||
"""把 B4 按「中枢末笔是否已确认」拆开——之前的统计把两种混在一起了。
|
||||
|
||||
用户指出:B4 有两种,**没确认的会出现然后消失,确认的不会**。而我在 step63/68
|
||||
里统计的是 `find_fast_bsp3` 的全部输出,它的返回列里根本没有确认标志
|
||||
(entry_idx/direction/bo_idx/pb_idx/lag/depth/zg/zd/width_pct/occ/zone_i),
|
||||
**两种被混在一起了**,所以「额外信号 74%」这个数字不能直接拿来说实盘。
|
||||
|
||||
确认状态在更上游,`zones_from_zs_list`:
|
||||
|
||||
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) # ← 静默退回 end_time
|
||||
|
||||
`ChanBI` 初始 `is_sure=False / sure_time=None`,确认时才 `set_is_sure(True, ...)`。
|
||||
所以**末笔未确认时 available_ts 退回 end_time,而 end_time 随笔延伸而移动**,
|
||||
中枢的可用时刻跟着漂 —— 这正是「出现然后消失」的那一种。笔一旦确认,
|
||||
`sure_time` 固定,中枢不再动。
|
||||
|
||||
这也解释了 §5.41 的数量级:中枢层面确认时刻只被改 6.5%,而我在信号层面看到 74%。
|
||||
|
||||
本脚本按 `zs.bi_list[-1].is_sure` 把信号拆成两组,分别看:
|
||||
额外率 未确认组应显著高(会出现然后消失),已确认组应接近 0
|
||||
收益 若亏损集中在未确认组,那么修法就是**信号侧加一道 is_sure 门**,
|
||||
而不是动出场参数或放弃 B4
|
||||
|
||||
⚠️ 同时要查的第二件事:**实盘路径到底交易哪一种。** `shadow_signal.py` 的三道
|
||||
滤网是同向 + 阶梯 + ATR 门控,**没有 is_sure 这一道**。若未确认组确实是亏损源,
|
||||
且实盘没有挡它,那这道门就是要补的东西。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step70_sure.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
WIN, MAX_GROW, GATE_BP = 2001, 500, 8.0
|
||||
|
||||
|
||||
def _sure_map(zs_list) -> dict:
|
||||
"""(zg, zd) -> 末笔是否已确认。zones_from_zs_list 会按 available_ts 重排,
|
||||
索引对不上,所以用中枢边界当键接回去。"""
|
||||
m = {}
|
||||
for zs in zs_list:
|
||||
bis = getattr(zs, "bi_list", []) or []
|
||||
if not bis:
|
||||
continue
|
||||
m[(round(float(zs.zg), 10), round(float(zs.zd), 10))] = \
|
||||
bool(getattr(bis[-1], "is_sure", False))
|
||||
return m
|
||||
|
||||
|
||||
def replay(sym: str, ltf: str, htf: str, rows: int,
|
||||
steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.fx_signal import extract_fx_signals, signals_to_frame
|
||||
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
|
||||
if df is None or len(df) < WIN + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, ltf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zsf = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
if not zsf:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zsf, cdf))
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
|
||||
dh = fetch_ohlcv(f"{sym}/USDT:USDT", htf, rows)
|
||||
tl = None
|
||||
if dh is not None and len(dh) > 500:
|
||||
ch = TF_DF(dh, 1, htf)
|
||||
tl = htf_fx_timeline(
|
||||
signals_to_frame(extract_fx_signals(ch, ch.dataframe)),
|
||||
ch.dataframe)
|
||||
|
||||
rec: dict[tuple, dict] = {}
|
||||
chan, anchor = None, 0
|
||||
for i in range(WIN, len(df)):
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, ltf)
|
||||
chan.init_stream(w, 1, ltf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z is None or z.empty:
|
||||
continue
|
||||
sm = _sure_map(zl)
|
||||
pg, pdn = z["zg"].shift(), z["zd"].shift()
|
||||
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
|
||||
z["zone_i"] = np.arange(len(z))
|
||||
z["z_sure"] = [
|
||||
sm.get((round(float(a), 10), round(float(b), 10)), False)
|
||||
for a, b in zip(z["zg"], z["zd"])]
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last]
|
||||
if s.empty or "zone_i" not in s.columns:
|
||||
continue
|
||||
s = s.merge(z[["zone_i", "z_above", "z_below", "z_sure"]],
|
||||
on="zone_i", how="left")
|
||||
if tl is not None:
|
||||
s = attach_htf_context(s, sub, tl, "h1")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (i, int(r.direction))
|
||||
if k in rec:
|
||||
continue
|
||||
push = getattr(r, "z_above" if r.direction == 1
|
||||
else "z_below", None)
|
||||
ag = getattr(r, "h1_agree", 0)
|
||||
rec[k] = {
|
||||
"sym": sym, "entry_idx": i, "direction": int(r.direction),
|
||||
"in_full": k in full_keys,
|
||||
"z_sure": bool(getattr(r, "z_sure", False)),
|
||||
"ladder_ok": int(bool(pd.notna(push) and bool(push))),
|
||||
"h1_agree": int(ag) if pd.notna(ag) else 0,
|
||||
}
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(list(rec.values()))
|
||||
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
r = r[(r.entry_idx < len(cdf) - 2)
|
||||
& np.isfinite(atr[r.entry_idx.values])
|
||||
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.entry_idx.values,
|
||||
"direction": r.direction.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c"):
|
||||
r[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
|
||||
r["gate_ok"] = (r.atr_pct * 1e4 >= GATE_BP).astype(int)
|
||||
r["pass_all"] = ((r.h1_agree == 1) & (r.ladder_ok == 1)
|
||||
& (r.gate_ok == 1)).astype(int)
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict | None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
if len(g) < 15:
|
||||
return None
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean()
|
||||
/ taker_notional(g.r.values, g.c.values).mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
print("\n" + "=" * 92)
|
||||
print("【一】判据:额外信号是不是集中在「末笔未确认」那一组")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.z_sure):
|
||||
rows.append({"中枢末笔": "已确认" if k else "未确认", "信号数": len(g),
|
||||
"额外(全量没有)": int((~g.in_full).sum()),
|
||||
"额外率": f"{(~g.in_full).mean()*100:.1f}%"})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
用户的判断:「没确认的会出现然后消失,确认的不会」。
|
||||
若已确认组额外率接近 0 -> 判断成立,之前 74% 是把两种混在一起统计的结果。""")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【二】亏损是不是也集中在未确认组")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.z_sure):
|
||||
nm = "已确认" if k else "未确认"
|
||||
for lab, x in [("全部", g), ("三道滤网后", g[g.pass_all == 1])]:
|
||||
s = perf(x)
|
||||
if s:
|
||||
rows.append({"中枢末笔": nm, "口径": lab, **s})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【三】若补一道 is_sure 门,实盘口径会变成什么样")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for nm, x in [
|
||||
("现状:三道滤网", d[d.pass_all == 1]),
|
||||
("**加 is_sure 门**", d[(d.pass_all == 1) & d.z_sure]),
|
||||
(" 对照:仅未确认", d[(d.pass_all == 1) & ~d.z_sure]),
|
||||
]:
|
||||
s = perf(x)
|
||||
if s:
|
||||
rows.append({"口径": nm, **s})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
⚠️ `shadow_signal.py` 的三道滤网是同向 + 阶梯 + ATR 门控,**没有 is_sure**。
|
||||
若加上这道门 PF 明显回升,那它就是要补进信号路径的东西。""")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--ltf", default="5m")
|
||||
ap.add_argument("--htf", default="30m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[确认态拆分] {len(syms)} 币 × {args.steps} 根 · {args.ltf}\n",
|
||||
flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.ltf, args.htf, args.rows,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""逐笔追「额外信号」:全量那一遍到底为什么没产出它。
|
||||
|
||||
用户说「原来的计算是没有问题的,是你算错了」。之前几轮都是统计口径,
|
||||
容易把自己的 bug 说成市场现象。这次不做统计,挑具体信号逐个对账。
|
||||
|
||||
对每个额外信号,把两边的中间量全摆出来:
|
||||
|
||||
回放侧 中枢 (zg,zd)、available_ts、扫描起点、入场根
|
||||
全量侧 同一个中枢是否存在、它的 available_ts、扫描区间 [start, start+200]
|
||||
入场根落不落在这个区间里、`diag` 记的拒绝原因
|
||||
|
||||
四种可能的结论,指向完全不同的处理:
|
||||
|
||||
A 全量里那个中枢的扫描区间**不覆盖**入场根
|
||||
-> available_ts 棘轮,机制成立,不是 bug
|
||||
B 中枢在全量里**不存在**
|
||||
-> 中枢集合本身有差异,要查是不是我窗口用错了
|
||||
C 区间覆盖了、全量却仍没出信号
|
||||
-> 两边输入不同(我传错了 df/zones),**是我的 bug**
|
||||
D 入场根索引对不上(差几根)
|
||||
-> 索引口径错,`chan.dataframe` 与原始 df 不是 1:1,**是我的 bug**
|
||||
|
||||
D 尤其要查:回放里我用原始 df 的下标 i 当 key,全量用的是 `cdf` 的 entry_idx,
|
||||
两者只有在 `TF_DF.dataframe` 与输入逐行对齐时才等价。
|
||||
"""
|
||||
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))
|
||||
|
||||
WIN, MAX_GROW, SCAN = 2001, 500, 200
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--sym", default="BTC")
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=3_000)
|
||||
ap.add_argument("--show", type=int, default=8)
|
||||
args = ap.parse_args()
|
||||
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
df = fetch_ohlcv(f"{args.sym}/USDT:USDT", args.tf, args.rows)
|
||||
df = df.iloc[-(WIN + args.steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, args.tf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
|
||||
print("=" * 92)
|
||||
print("【0】先查 D:索引口径是否 1:1")
|
||||
print("=" * 92)
|
||||
print(f"原始 df 行数 {len(df)} · TF_DF.dataframe 行数 {len(cdf)} "
|
||||
f"-> {'一致' if len(df) == len(cdf) else '**不一致,索引口径有问题**'}")
|
||||
if len(df) == len(cdf):
|
||||
t_df = pd.to_datetime(df["date"])
|
||||
t_cd = pd.to_datetime(cdf["date"])
|
||||
if t_df.dt.tz is not None:
|
||||
t_df = t_df.dt.tz_localize(None)
|
||||
if t_cd.dt.tz is not None:
|
||||
t_cd = t_cd.dt.tz_localize(None)
|
||||
same = int((t_df.values == t_cd.values).sum())
|
||||
print(f"逐行时间戳相同 {same}/{len(df)} "
|
||||
f"-> {'逐行对齐' if same == len(df) else '**有错位**'}")
|
||||
|
||||
zsf = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
zf = zones_from_zs_list(zsf, cdf)
|
||||
diag_full: dict = {}
|
||||
sig_full = find_fast_bsp3(cdf, zf, diag=diag_full)
|
||||
fkeys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()}
|
||||
ts = cdf["timestamp"].to_numpy()
|
||||
print(f"\n全量:中枢 {len(zf)} 个,信号 {len(sig_full)} 个")
|
||||
print(f"全量 diag:{diag_full}")
|
||||
|
||||
# ---- 回放 ----
|
||||
rec = []
|
||||
chan, anchor = None, 0
|
||||
for i in range(WIN, len(df)):
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, args.tf)
|
||||
chan.init_stream(w, 1, args.tf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z is None or z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last]
|
||||
if s.empty:
|
||||
continue
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
zi = int(r.zone_i)
|
||||
rec.append({
|
||||
"i": i, "d": int(r.direction),
|
||||
"in_full": (i, int(r.direction)) in fkeys,
|
||||
"zg": float(z.zg.iloc[zi]), "zd": float(z.zd.iloc[zi]),
|
||||
"avail_rt": int(z.available_ts.iloc[zi]),
|
||||
"win_len": len(sub),
|
||||
"bo": int(r.bo_idx), "last": last,
|
||||
})
|
||||
rp = pd.DataFrame(rec)
|
||||
if rp.empty:
|
||||
print("回放无信号")
|
||||
return
|
||||
print(f"\n回放:信号 {len(rp)} 个 · 其中全量也有 "
|
||||
f"{int(rp.in_full.sum())} · 额外 {int((~rp.in_full).sum())}")
|
||||
|
||||
# ---- 逐笔对账 ----
|
||||
print("\n" + "=" * 92)
|
||||
print(f"【1】抽 {args.show} 个额外信号逐笔对账")
|
||||
print("=" * 92)
|
||||
fzg, fzd = zf.zg.to_numpy(float), zf.zd.to_numpy(float)
|
||||
fav = zf.available_ts.to_numpy()
|
||||
n = len(cdf)
|
||||
rows = []
|
||||
for r in rp[~rp.in_full].head(args.show).itertuples():
|
||||
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
|
||||
if not m.any():
|
||||
rows.append({"入场根": r.i, "中枢在全量": "**不存在**",
|
||||
"结论": "B 中枢集合有差异"})
|
||||
continue
|
||||
j = int(np.argmax(m))
|
||||
start = int(np.searchsorted(ts, fav[j], side="left"))
|
||||
end = min(start + SCAN, n)
|
||||
cover = start <= r.i < end
|
||||
rows.append({
|
||||
"入场根": r.i, "中枢在全量": "存在",
|
||||
"回放avail根": int(np.searchsorted(ts, r.avail_rt, side="left")),
|
||||
"全量avail根": start,
|
||||
"棘轮(根)": start - int(np.searchsorted(ts, r.avail_rt,
|
||||
side="left")),
|
||||
"全量扫描区间": f"[{start},{end})",
|
||||
"覆盖入场根": "是" if cover else "否",
|
||||
"结论": "C **是我的bug**" if cover else "A 棘轮,机制成立",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
A = 全量扫描区间不覆盖入场根(available_ts 棘轮后移),机制成立
|
||||
B = 中枢在全量里不存在 -> 中枢集合有差异,要查窗口
|
||||
C = 区间覆盖了全量却没出信号 -> 两边输入不同,是我的 bug""")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【2】全体额外信号按结论归类")
|
||||
print("=" * 92)
|
||||
cnt = {"A 棘轮": 0, "B 中枢不存在": 0, "C 我的bug": 0}
|
||||
for r in rp[~rp.in_full].itertuples():
|
||||
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
|
||||
if not m.any():
|
||||
cnt["B 中枢不存在"] += 1
|
||||
continue
|
||||
j = int(np.argmax(m))
|
||||
start = int(np.searchsorted(ts, fav[j], side="left"))
|
||||
cnt["C 我的bug" if start <= r.i < min(start + SCAN, n)
|
||||
else "A 棘轮"] += 1
|
||||
tot = max(sum(cnt.values()), 1)
|
||||
print(pd.DataFrame([{"结论": k, "个数": v, "占比": f"{v/tot*100:.1f}%"}
|
||||
for k, v in cnt.items()]).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【3】按信号条数统计是否被同一中枢的重复触发放大了")
|
||||
print("=" * 92)
|
||||
print("上表可见 2691/2707/2750 的全量 avail 同为 2767,是**同一个中枢**触发三次。")
|
||||
print("`max_per_zone=1` 只保证「每次扫描返回一个」,但扫描起点随棘轮后移,")
|
||||
print("越过旧入场点后同一中枢会重新产出「第一个」——于是按条数统计被放大。\n")
|
||||
rows = []
|
||||
for nm, x in [("额外", rp[~rp.in_full]), ("全量也有", rp[rp.in_full])]:
|
||||
if x.empty:
|
||||
continue
|
||||
nz = x.groupby(["zg", "zd"]).size()
|
||||
rows.append({"分组": nm, "信号条数": len(x), "不同中枢数": len(nz),
|
||||
"每中枢触发": round(len(x) / len(nz), 2),
|
||||
"最多触发": int(nz.max())})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
若「额外」组每中枢触发 >> 1 而「全量也有」组 ≈ 1,则先前那个「额外信号占 74%」
|
||||
是**按条数**统计的放大结果,不等于实盘会多开 74% 的仓。
|
||||
真实多开多少,取决于执行层对同一中枢是否去重/冷却 —— 那一层仍未审计。""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user