16 Commits
Author SHA1 Message Date
jackyu66git 6b72ba226b Merge remote-tracking branch 'origin/chan' into chan 2026-08-31 23:03:28 +08:00
jackyu66gitandCursor f14ef540c1 research: 订正——「额外信号 74%」是按条数统计的放大,按中枢是 52%
用户指出「原来的计算是没有问题的,是你算错了」。改用逐笔对账(不做统计,
把两边中间量全摆出来),结论一半一半。

引擎与我的口径都没问题:36 个额外信号 100% 归为 A 类(全量扫描区间不覆盖
入场根,即 available_ts 棘轮),0% 属于我的 bug;索引逐行对齐(5001=5001,
时间戳全同);额外信号的中枢在全量里全都存在且 (zg,zd) 完全一致,中枢不重画。
棘轮幅度实测 32~234 根。

但同一张表暴露了真正算错的地方:全量每个中枢恰好产出 1.00 个信号,回放里
同一中枢平均触发 2.12 次、最多 5 次。max_per_zone=1 只保证每次扫描返回一个,
但扫描起点随棘轮后移、越过旧入场点,同一中枢会重新产出「第一个」。

所以先前的「额外信号占 74%」不成立——那是按信号条数统计的,按中枢算是
17/33≈52%。这也把数量级对上了:§5.41 的 6.5% 分母是全部中枢,而会产出信号的
中枢里被改过的占比自然更高,两个数不矛盾,先前直接对比也是错的。

仍成立:棘轮机制、额外信号确实亏钱。已推翻:「实盘会多开 74% 的仓」。
真实倍数取决于执行层是否按中枢去重/冷却,该层从未审计,现已提为最高优先级
的前置项——查清前不要据 PF 0.73 动实盘参数。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:38:52 +08:00
jackyu66gitandCursor 7585491481 research: 订正额外信号的机制——不是重画,是中枢终点在实时不可知
我先前把 B4 的额外信号归因为「中枢重画」,用户两次指出后逐条查证,归因是错的:

① 中枢边界不重画(用户对)。zg/zd 由前三笔定死,verify_window_sens/step39 验过。

② 中枢只由已确认的笔构成,这是结构性保证:cal_bi_zs_list_pure 要求
   bi1/bi2/bi3.is_sure,延伸时要求 leave_bi/back_bi.is_sure。
   step70 实测 789 个信号全部 z_sure=True,用户说的浅色 B4 在研究路径不存在。
   因此我提的「补一道 is_sure 门」是空操作,实测 PF 0.73→0.73,已标记不要再提。

③ 真机制是 available_ts 棘轮,§5.41 早写明:改动不是已确认的笔被推翻,
   而是中枢又吸收了新笔、bis[-1] 换人。available_ts 取末笔 sure_time,
   于是往后棘轮,find_fast_bsp3 的 200 根扫描窗口整体右移。
   消失的不是笔,是中枢的终点。

顺带澄清一个伪问题:中枢跨度 > 200 根不会导致突破落不进窗口,
因为扫描起点是中枢结束(末笔确认)而非起点,此时价格已在离开中枢。

仍未对上的是数量级:中枢层面 6.5% vs 信号层面 75%。假设是 max_per_zone=1
的放大(起点棘轮越过旧入场点,同一中枢反复产出「第一个」)。
step69_mechanism.py 已写好判据但三次后台运行被中断,标为下一步前置项。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:31:46 +08:00
jackyu66gitandCursor e21f1103c5 research: 1m 复验确认 B4 实盘口径 PF 0.72,并修正 ATR 门控的解读
用户指出 1m 参数与其他级别不同。核查:出场参数 SL2/3ATR减半/留损2/目标8ATR/48根
两边同形状,本轮用对了;但 ATR≥8bp 门控是照 1m 标定的,文档明确它在 5m 上惰性
(触发 2.6%,「无害也无用」)。所以 5m 那轮的「三道全开」实际只有两道在起作用,
先前那句「ATR 门控没用」不成立——它在 5m 上本来就不该起作用。

1m 复验(三道齐全)结论与 5m 一致:
  无过滤    712 笔 / 77% 额外 / PF 0.74
  三道全开  122 笔 / 72% 额外 / PF 0.72
  其中回测也有的 34 笔 PF 2.07,额外的 88 笔 PF 0.36 (t −2.73)

ATR 门控在 1m 上确实咬得凶(712→389,刷掉 45%),但对额外信号无区分力:
刷完额外占比仍是 77%。三道合计砍掉 83% 成交量,额外占比只从 77% 降到 72%。

新增两条前置待办:1m 关键分组只剩 34 笔,据此改实盘前应扩样本;
另外「额外信号是否真的每个都下单」取决于执行层,这一层尚未审计。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:57:53 +08:00
jackyu66gitandCursor 84ee59ecfb research: B4 时点干净,但实盘做的不是回测那批单(实盘口径 PF 0.73 vs 回测 4.21)
step63 因果回放:B4 的时点完全干净——100% 召回、100% 准时、零滞后,
一类那个把 PF 从 2.35 打到 0.92 的坑(§3.396)B4 没有。

但回放多产出 592 个全量口径里不存在的信号,PF 0.46 / t −5.03。
step68 按实盘口径复测(2001 根滚动窗口、每 500 根 init_stream、只做当根收盘,
逐行对齐 shadow_signal.py),并把三道滤网全测一遍:

  无过滤    789 笔,额外占比 75%,PF 0.64
  三道全开  195 笔,额外占比 74%,PF 0.73

滤网把成交量砍掉 75% 却几乎不改变额外信号占比——按同比例刷掉好的和坏的。
拆开看:回测里也有的 51 笔 PF 4.21/t+5.90,回测里没有的 144 笔 PF 0.26/t−6.21。

即回测报的 4.21 拿不到:那 51 笔要事后全量重算才能识别,实盘当下分不出来。
机制是重画——实时算出的中枢,数据变多后被修正掉。与 §3.396 同类:
一类错在时点,B4 错在存在性。

顺带闭掉一条待办:2001 根窗口与 2万→4万根增长窗口跑出完全相同的 789/197/592,
窗口左边界效应判为否。

限定:本轮是 5m/30m 而实盘跑 1m,需单独复验;同向过滤器被开了未来函数后门
(HTF 时间线用全量算),真实盘只会更差。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:46:19 +08:00
jackyu66gitandCursor 93ed315a4b research: 一类线门槛量化——全局只有一个变量,精度需 67~73%
把 §3.3992 的天花板换成实时可算的 ext_run 选样配结构止损:5m 0.51、15m 0.48,
对比上界 2.29/4.11,兑现不了。但数字对不上(Q4 真端点浓度已 44%),拆开后
得到本轮最有价值的一张表:

ext_run 在真端点**内部**毫无反向选样(5m 各档 2.53/1.80/2.61/2.22 持平,
15m 单调递增到 6.10),在非真端点内部也毫无区分力(恒在 0.11~0.18)。
全局只有「是不是真端点」这一个变量在起作用,其余特征都是它的噪声代理。

这也修正了 §3.399:ext_run 的符号翻转不是拟合,它确实提纯(28.7%→44%),
只是幅度远远不够。

于是 PF 退化为两组按精度 p 的混合,解出盈亏平衡精度:5m 72.6%、15m 67.1%。
即要求实时判「这是不是那个底」的准确率达七成,而现在是 28.7%。

顺带解释了为什么 B4 能做而一类不能:B4 是突破后的延续信号,不需要判断反转;
一类的全部难度集中在这一个二分类上。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:17:48 +08:00
jackyu66gitandCursor ed741c16b8 research: 一类线重开——病根是选样×止损的交互,不是没机会
用户问「修改 B1/B2 的识别规则呢」,查下来推翻了 §3.399 的关闭结论。

step65 先量机会本身:直接取 seg_list(不经过检测器),线段中位幅度 11.4 ATR,
扣掉 2.9 ATR 确认成本还剩 8.5,是止损的 4.26 倍,93% 的线段装得下。
五币四级别一致。**滞后不是瓶颈**,与原先预期相反。

但这与 §3.397 矛盾(空间在却拿不到),step66 给出解答:之前六次进攻每次只动
一半。完美选样配 2ATR 止损是 0.70/0.83,无选样配结构止损是 0.36,
**两个一起是 2.29(5m)/4.11(15m)**,胜率 36%→63% / 44%→70%。是交互不是叠加。

机制来自 MFE/MAE:真底那批逆向行程中位仅 2.24 ATR,2 ATR 止损打掉了 51~58%
的好单;非真底那批逆向行程中位 4.76,放宽只是亏更多。固定 2 ATR 同时做错两件事。

⚠️ 上界含两个未来函数(线段端点选样、sure_time 入场),是靶子不是策略。

顺带证伪一个我自己的猜测:step58 首轮用 r_scale=True 把 3.9 ATR 止损对应的
runner 目标推到 15.6 ATR(比整段行情还长)。加 --abs-targets 改绝对目标重跑,
PF 仍是 0.36,与 r_scale 版完全相同。首轮结论正确,只是理由错了。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:09:38 +08:00
jackyu66gitandCursor 380d6d61cd research: 二类买卖点定性为无信息,一二类线整体关闭
用户提出悖论:二类按定义依赖一类(引擎里 B2 确实被 first_bsp_bi_div 门控),
一类既已证否,二类凭什么好。当时有个值得测的反驳——二类多要求「确实反弹」
且「回踩守住 B1 低点」,这是「底是真的」的事后确认,而 §3.397 的诊断恰恰是
一类缺这个确认。若成立,二类反而是一类里被验证过的子集。

测下来悖论成立,但机制不同:二类不是继承了一类的错,是信息为零。
一类 t = −17~−22(强烈指反,有信息只是方向被滞后翻了面),二类原方向
t = −0.8~−2.4、反手 +0.2~+1.7,两边都贴着零,连反手都没有。

另一发现是几何:二类的天然止损位是 B1 的低点,但反弹加回踩之后入场价已在
其上方 4.87 ATR(一类 2.90),2 ATR 止损有 99~100% 落在结构内侧。
step58 在一类上放宽到结构位只把 PF 从 0.22 抬到 0.36,二类缺口大 68%。

⚠️ 报表里二类「命中线段顶点 0.0%」是我用错口径,已在文档标注:二类按定义
是反弹后的更高低点,与线段顶点不可能重合,该指标只对自称在极值的一类有效。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 22:54:31 +08:00
jackyu66gitandCursor 7292ef5dd4 research: 低滞后+质量过滤组合失败,过滤器符号翻转,一类线关闭
把 step62 的 ext_run 质量过滤接到 step56 的低滞后版上——两者各解决一半约束,
是唯一同时处理滞后和识别精度的路径。

失败,且失败方式本身是判据:过滤器符号反了。5m 上延伸度分档在滞后版是
Q1 0.12 / Q4 0.46(越延伸越好),在低滞后版是 Q1 0.58 / Q4 0.33(越短越好)。
叠加后 PF 从 0.37 掉到 0.30~0.33,比不过滤更差。div 同样翻转。

同一批信号、同一个特征,换个入场时点最优方向就反过来,说明这些过滤效果是
入场时点的交互产物而非信号的稳定属性,继续挑阈值就是拟合噪声。

一类线六次独立进攻全部止步 1.0 以下:引擎原生 0.24、反手 0.92(因果回放后)、
实时重写 0.41、结构止损 0.36、用未来函数选样 0.83、质量过滤 0.54、
低滞后+过滤 0.33。第五项尤其说明问题——即使选样做到完美也只到 0.83。

判定关闭。病根是入场价已在结构底上方 2.9 ATR、实盘再晚 15 根,来自「等笔确认」
机制本身而非参数。识别可以优化(精度能翻倍),但识别从来不是瓶颈。

fast_bsp1 顺带补 ext_atr 字段,用 ATR 归一才与 step62 的 ext_run 同口径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 22:03:59 +08:00
jackyu66gitandCursor 5695d8e983 research: 趋势末端识别(step62),ext_run 单调区分但 PF 仍不过 1
用户指出很多一二类实际在趋势中途被识别而非末期,若真在末期即使有延迟也该走出
行情。用 step60 的线段顶点当标签找实时可算的区分特征。

ext_run(极值越过中枢边界几个 ATR)单调有效:5m 上四分位的命中率是
12.0/22.1/37.0/43.8%,PF 0.12/0.11/0.22/0.46。短延伸那批就是趋势中途被识别的,
占一半且 PF 仅 0.11。最佳组合 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),
是对 MACD 面积判据的直接证伪;趋势级数无区分力(命中率 28.5/30.6/28.6/25.5%
基本持平)。

另修正一个我先前的猜测:以为引擎漏了缠论「趋势 vs 盘整」前提,实测该条件在
2504 笔上恒为 True——B1 要求 enter_bi.dir == leave_bi.dir == DOWN,中枢向下进
向下出本身就定义了它嵌在下跌趋势里,引擎已隐含强制,过滤器无从添加。
zs_count 也不可用,它是全局中枢序号而非趋势内序号。

结论:识别可优化且幅度不小,但不是瓶颈,瓶颈是入场时点。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 22:00:52 +08:00
jackyu66git 78708ddeee research: 极值点波动率(step61),观察成立但既不解释滞后也不解释亏损
用户提出一二类长在极值点、该区域波动大、这可能是确认慢的原因。拆成三条
子命题分别测。

① 成立:5m 上 B1 的 atr_z 中位 1.224、74% 高于基准,而所有笔端点的基准恰好
落在 50%(构造正确的旁证)。但二类是镜像——B2/S2 的 atr_z 中位 0.94~0.97、
仅四成高于基准,长在低波动区,因为它是首轮反转冲动之后的回抽。所以
「一二类都在极值点」对一类成立、对二类不成立。

② 只沾边:corr(atr_z, lag_bars) 仅 +0.10,滞后中位在四个 atr_z 分档里是
8/7/8/9 根,几乎不动。滞后是结构性的,笔要等分型确认,与波动率基本无关。

③ 回落属实但不是死因:入场后 48 根平均 ATR 是入场时的 0.896,目标确实按虚高
ATR 定、绝对价格高估约 10%。但一类只有 9.4% 走到 3ATR 减仓(三类 30.7%),
81.3% 直接止损、超时仅 17.9%。若死因是目标够不着,超时占比该很高。所以一类
不是走不动,是入场后立刻反向——与 §3.397 的诊断一致。
2026-08-28 21:51:59 +08:00
jackyu66gitandCursor e137d92b50 research: 因果回放推翻一类反手(step59),线段顶点验证识别有效但不够(step60)
step59 逐根回放:init_stream 预热后逐根 append_bar,每根重算笔中枢与 bsp,
记录信号首现根并按它入场。信号身份用「类型+极值KLC时刻」而非 sure_time,
后者正是会被重画的字段。必须逐根,分段重建等于多给信息。

结果把 §3.395 推翻了:召回 100%、幻影 0,即存在性是因果的;但首现根比全量
sure_time 晚中位 15 根、P90 31 根,0% 能准时拿到。按真实首现根入场,
B1 反手 PF 2.35→0.92、S1 2.75→1.30,t 0.20/0.64 完全不显著。

教训:sure_time 是引擎事后标注的确认时刻,不等于可执行时刻。任何拿它当
entry 的回测都要先过逐根回放。

step60 用线段终点当标准答案验证识别本身。对照组取所有同向笔端点——B1 按构造
就长在笔低点上,不设这个基准任何绝对命中率都无法解读。5m 上 B1 命中 30.3%
对基准 15.0%,提升 2.02 倍,S1 1.81 倍;B3/S3 恰为 0%,符合三类长在趋势
中段的预期,两者互为标签有效性旁证。

所以识别是对的,但精度只有 30%,且那 70% 噪声 PF 只有 0.08。更关键的是即使
用未来函数把精度提到 100%,命中组也只有 PF 0.70~0.83,仍不赚钱——因为入场价
已在结构底上方 2.90 ATR。一类线三层逐层否定后到此为止。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 21:38:59 +08:00
jackyu66gitandCursor 9efbae6ade research: 结构止损对照(step58),止损确实太紧但不是主因
用户提出 B1 做多的止损可能挂在 B2 低点上方,于是在那次正常回抽处被打掉。
观察成立且比预估严重:入场价到结构极值中位 2.90 ATR,2ATR 止损落在结构位
内侧 0.90 ATR,88.6% 的一类做多价格不用回踩到前低就已出局。

修掉有改善但救不活:结构位外侧 1.5ATR 止损把胜率从 16.7% 抬到 33.1%、
PF 从 0.22 到 0.36,仍深度为负。同口径下反手做空 PF 2.15~2.53。

做这个对照时目标必须随止损等比放大(恒定 1.5R 减半/4R 收尾),否则放宽止损
却不放大目标会把盈亏比压到 1 以下,测出来的「放宽无效」是自证的。故
walk_exits 不能用,自带了逐笔止损的模拟器。

最有说服力的读法是结构+1.5 那档:止损宽达 4.4 ATR 仍有 67% 被打掉,即
三分之二的一类信号会在 48 根内跌破结构低点再多走 1.5 ATR。缠论里「B2 回踩
不破前低」在本市场多数时候不成立,趋势确实越过第二类买卖点继续走原方向。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 20:31:06 +08:00
jackyu66gitandCursor be3602f5ba research: 一类反手(step57),六年五币双向全正,PF 2.3~3.0
用户问一类二类的识别是不是错了。查下来是代码没写错、信号该反着用。

先排除一个错误猜测:check_bi_div 只比 MACD 面积、不检查创新极值,等于把
缠论背驰定义丢了一半。看着像致命缺陷,实测 93% 的离开笔本来就创了新极值,
解释不了 PF 0.2。

真正的线索是胜率方向:B1 胜率 18.6%,而 SL2/TP3 随机入场约 40%,远低于
随机说明样本带信息只是指反了。拿相反方向重跑出场(路径依赖,不能取负号),
5m/15m 上 PF 2.35~3.03、t +10~+13。

step57 三关全过:5 币全正、前后半段 2.81/2.81 与 2.55/2.51 几乎不变、
2021~2026 连续六年全正、多空两边都正。余量 15m 33~76bp,B4 只有约 3bp。

机理是滞后把信号变成了相反的交易:信号在 leave_bi.sure_time 发出,中位滞后
8~9 根,此时价格已从低点反弹 1.68 ATR。引擎想说抄底,它给的时间戳对应的却是
反弹已走完——在下跌趋势里于此处做空是标准的顺势回调入场。与 B4 的突破延续
是不同的入场原型,可能互补。

三项验证未做完前不许上实盘:因果性(中枢右边缘已知会重画,需流式回放)、
消融(是不是反弹就做空都赚)、与 B4 的重叠度。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 20:12:11 +08:00
jackyu66gitandCursor 409dc2f020 research: 实时版一类(step56),证明滞后不是病根,一类没有边
step55 判定引擎 B1/S1 不可用后,照 B3→B4 的路子做了实时版:把「中枢已成 /
向下离开 / 背驰 / 触发」四步全换成当根可判的代理,滞后从 8~9 根压到 ≤1 根。
背驰能实时判的关键是进入段面积在中枢确认时已是历史,只有离开段需要逐根累加。

没救回来。5m 上 fastB1 一买 PF 0.38 / 一卖 0.36,15m 同量级,比引擎版的
0.20~0.24 有改善但离 1.0 很远。

真正的价值在对照组和滞后分档两处:
- 同数据同出场同管线,fastB3 跑出 PF 1.83~2.06 / t +8.5~+10.7,排除了
  「管线接错所以为负」。
- lag ≤1 根(≈买在极值上)那一档 PF 仍只有 0.40。即滞后清零也救不活,
  §3.393 里「等 8 根导致几何劣势」的解释只对了一半。

结论与 §3.4 消融、§3.391 的 mom60 相互印证:这族信号的 alpha 在顺势延续上,
不在反转上。把反转信号提前,它还是反转信号。一类到此为止。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:55:23 +08:00
jackyu66gitandCursor 30592889aa research: 一二类买卖点可行性探针(step55),结论为不可用
用户提出把 B4 的研究思路搬到第一/第二类买卖点,并指定 5m/15m 测。

滞后先于收益测,结论是用户判断正确:B1/B2/B3 滞后中位都是 8~9 根,
三类共用 find_all_bsp 的「中枢 is_sure + 笔 is_sure + sure_time」,
滞后不是区分它们的变量。

收益全负,一类最差:5m 上 B1 PF 0.24 / 胜率 18.6% / t −17.3,
S1 PF 0.20 / 胜率 14.8% / t −22.3,15m 同量级。B3 跑出 0.64/0.76、
HANDOFF §4 记的是 0.66,口径校验通过,所以 B1/B2 的数可信。

一类烂得彻底是几何决定的:等 8 根后价格已朝上跑 1.68 ATR,新入场价往下
2 ATR 的止损落在比原始低点还低 0.3 ATR 处,几乎贴着极值。同样的滞后在
顺势突破上只是追高,在逆势反转上是加倍惩罚。

探针里修掉两个会静默出错的地方:cdf.date 是 datetime64[ms] 而
Timestamp.value 是纳秒,手工转 int64 比较会让 searchsorted 全部落到末尾
且不报错(这是之前跑出 0 条的原因);速率对照未按币归一,拿 5 币的数去
比 10 币的 B4 基线等于凭空打对折。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:48:59 +08:00
19 changed files with 4847 additions and 0 deletions
+743
View File
@@ -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.125m 2.31~2.70 |
| 前后半段 | 15m 2.81 / 2.815m 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/50S1 65/65 |
| 幻影(回放发过、全量没有的) | **0** |
| 首现根 全量 `sure_time` | 中位 **+15 根**P25 +11P75 +22P90 +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.83t 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.13Q4(最弱)
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.75m 反手 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 ATR5m/ 2.0815m**
即 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)。差距可能来自
+225
View File
@@ -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))
+348
View File
@@ -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 / step4810 币 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
# 必须走 fullcheck_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()
+224
View File
@@ -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()
+84
View File
@@ -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()
+196
View File
@@ -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()
+236
View File
@@ -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 全量口径 5mB1反手 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()
+242
View File
@@ -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()
+252
View File
@@ -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()
+271
View File
@@ -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()
+246
View File
@@ -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()
+257
View File
@@ -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()
+180
View File
@@ -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()
+152
View File
@@ -0,0 +1,152 @@
"""改识别规则到底有没有用:把天花板一次性测到顶。
用户问 B1/B2 的识别规则呢step65 让这个问题变得可判定但也制造了一个
必须解释的矛盾
线段中位 11.4 ATR进场花掉 2.9 8.5 2 ATR 止损的 4.26
step60 用未来函数选出真底的那批 B1PF 仍只有 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()
+195
View File
@@ -0,0 +1,195 @@
"""把 step66 的天花板换成实时可算的选样,看能兑现多少。
step66 测出上界**未来函数选样 + 结构止损** 5m PF 2.2915m 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()
+265
View File
@@ -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()
+240
View File
@@ -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()
+280
View File
@@ -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()
+211
View File
@@ -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()