Compare commits
20
Commits
080ff7d20e
...
chan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b1b500108 | ||
|
|
c22cd48f36 | ||
|
|
b301ad22c9 | ||
|
|
2cd50f1e01 | ||
|
|
6b72ba226b | ||
|
|
f14ef540c1 | ||
|
|
7585491481 | ||
|
|
e21f1103c5 | ||
|
|
84ee59ecfb | ||
|
|
93ed315a4b | ||
|
|
ed741c16b8 | ||
|
|
380d6d61cd | ||
|
|
7292ef5dd4 | ||
|
|
5695d8e983 | ||
|
|
78708ddeee | ||
|
|
e137d92b50 | ||
|
|
9efbae6ade | ||
|
|
be3602f5ba | ||
|
|
409dc2f020 | ||
|
|
30592889aa |
@@ -49,6 +49,7 @@ research/out/run_meta_*.json
|
||||
|
||||
# Telegram 凭据。**不要提交**
|
||||
research/live/deploy/tg.env
|
||||
research/.tg.env
|
||||
|
||||
# 生产状态与信号总线。刻意放在仓库外(LIVE_HOME / BUS_DIR),这几条只防
|
||||
# 有人把它们指回仓库里:里面是日亏损累计与已处理信号键,被 git clean
|
||||
|
||||
@@ -43,6 +43,29 @@ class ChanSEG():
|
||||
self.macd_hist = macd_hist
|
||||
def set_macd_div(self, macd_div):
|
||||
self.macd_div = macd_div
|
||||
def cal_macdhist(self):
|
||||
# 线段面积 = 同向笔 MACD 柱面积之和(与笔面积口径一致)
|
||||
acc = 0.0
|
||||
seg_dir_name = getattr(self.dir, 'name', None)
|
||||
for bi in self.bi_list:
|
||||
if bi is None:
|
||||
continue
|
||||
if getattr(getattr(bi, 'dir', None), 'name', None) != seg_dir_name:
|
||||
continue
|
||||
acc += float(bi.macd_hist or 0)
|
||||
self.macd_hist = acc
|
||||
return acc
|
||||
def cal_macd_div(self):
|
||||
# 与前一个同向线段比面积:seg.pre 是反向邻段,pre.pre 才是同向
|
||||
self.macd_div = 0.0
|
||||
prev = self.pre.pre if self.pre and self.pre.pre else None
|
||||
if prev is None:
|
||||
return 0.0
|
||||
prev_hist = float(prev.macd_hist or 0)
|
||||
if prev_hist == 0:
|
||||
return 0.0
|
||||
self.macd_div = float(self.macd_hist or 0) / prev_hist
|
||||
return self.macd_div
|
||||
def set_end_bi(self, bi: ChanBI, sure_bi: ChanBI):
|
||||
self.end_bi = bi
|
||||
if bi and bi.is_sure:
|
||||
|
||||
@@ -668,13 +668,22 @@ class BiBuilderMixin:
|
||||
return bi_list
|
||||
|
||||
def check_top_fx(self, last_bottom, klc):
|
||||
if (last_bottom.high > klc.pre.low or last_bottom.high > klc.next.low) and (klc.index - last_bottom.index < 100):
|
||||
#严格笔
|
||||
#last_bottom_high = max(last_bottom.high, last_bottom.pre.high, last_bottom.next.high)
|
||||
#缠论原著笔
|
||||
last_bottom_high = last_bottom.high
|
||||
if (last_bottom_high > klc.pre.low or last_bottom_high > klc.next.low) and (klc.index - last_bottom.index < 100):
|
||||
#print(klc.end_time, "check_top_fx False")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_bottom_fx(self, last_top, klc):
|
||||
if (last_top.low < klc.pre.high or last_top.low < klc.next.high) and (klc.index - last_top.index < 100):
|
||||
#严格笔
|
||||
#last_top_low = min(last_top.low, last_top.pre.low, last_top.next.low)
|
||||
#缠论原著笔
|
||||
last_top_low = last_top.low
|
||||
if (last_top_low < klc.pre.high or last_top_low < klc.next.high) and (klc.index - last_top.index < 100):
|
||||
return False
|
||||
return True
|
||||
# 线段内的中枢
|
||||
|
||||
@@ -60,8 +60,8 @@ class IndicatorsBuilderMixin:
|
||||
30 多列的开销比全部 TA 计算本身还大(2001 行实测 TA 合计 2.5ms,
|
||||
逐列赋值 3.6ms)。增量路径每根都要走一遍,这笔开销是白付的。
|
||||
"""
|
||||
fast = 26
|
||||
slow = 52
|
||||
fast = 12
|
||||
slow = 26
|
||||
period = 9
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||
|
||||
@@ -974,6 +974,708 @@ mom10 同形状(Q2 最好、Q4 最差),样本内外一致。**势要有—
|
||||
> **② 在几十个分组里挑出的最显著那个,默认它是噪声,除非它能在另一个总体上
|
||||
> 复现。** p 值不做多重比较校正就等于没做检验。
|
||||
|
||||
### 3.393 一类/二类买卖点:引擎实现下全是负的,一类尤其糟(step55)
|
||||
|
||||
用户提出把 B4 那套研究思路搬到一二类上,并指定用 5m/15m 测。
|
||||
|
||||
**先量滞后,不先跑收益**——`Chan_BSP_TYPE` 的枚举注释里写着 B4 存在的全部意义
|
||||
就是「几何位置同 B3/S3,但不等笔确认」,说明这个项目早就付过等笔确认的学费。
|
||||
|
||||
**① 滞后:三类完全一样(用户的判断,实测确认)**
|
||||
|
||||
| 类型 | 5m 滞后中位 | 15m 滞后中位 | 5m 漂移中位 | 占 2ATR 止损 |
|
||||
|---|---|---|---|---|
|
||||
| B1 | 8 根 | 9 根 | 1.68 ATR | 84% |
|
||||
| B2 | 8 根 | 8 根 | 2.03 ATR | 101% |
|
||||
| B3 | 8 根 | 9 根 | 1.95 ATR | 98% |
|
||||
|
||||
三类都卡在同一处:`中枢 is_sure` + `笔 is_sure` + `sure_time`。
|
||||
**所以滞后不是区分一二三类的变量**,它是 `find_all_bsp` 这个实现的共同属性。
|
||||
|
||||
⚠️ 「漂移为正的占比 100%」是**定义决定的,不是发现**:笔之所以在那里结束,
|
||||
正是因为价格从极值反向走了,`bi.end_klc` 就是极值点。有信息量的是幅度不是符号。
|
||||
|
||||
**② 收益:全负,一类是最差的一档**(5m / 15m,5 币,出场用实盘的 2/3/8/2/48)
|
||||
|
||||
| 类型 | 5m 笔数 | 5m 胜率 | 5m 毛R | 5m PF | 5m t值 | 15m PF | 15m t值 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **B1** | 623 | **18.6%** | −0.592 | **0.24** | **−17.3** | 0.21 | −18.9 |
|
||||
| **S1** | 644 | **14.8%** | −0.675 | **0.20** | **−22.3** | 0.23 | −15.9 |
|
||||
| B2 | 293 | 35.8% | −0.104 | 0.73 | −1.4 | 0.85 | −0.8 |
|
||||
| S2 | 288 | 36.8% | −0.115 | 0.82 | −1.6 | 0.75 | −2.4 |
|
||||
| B3 | 2806 | 33.3% | −0.187 | 0.64 | −8.0 | 0.76 | −5.3 |
|
||||
| S3 | 2685 | 35.4% | −0.135 | 0.70 | −5.6 | 0.73 | −4.8 |
|
||||
|
||||
**口径校验通过**:B3 跑出 PF 0.64/0.76,§4 记的引擎 B3 是 0.66 —— 接线是对的,
|
||||
所以 B1/B2 的数可以信。
|
||||
|
||||
**③ 一类为什么烂得这么彻底:它的结构自带反向杠杆**
|
||||
|
||||
B1 买点的理想入场是那一笔的低点。等到 `sure_time` 可以动手时,价格已经**朝上
|
||||
跑了 1.68 ATR**。于是新入场价往下 2 ATR 的止损,落在**比原始低点还低 0.3 ATR**
|
||||
的位置——几乎贴着极值,任何一次回踩都打掉;而 3 ATR 的减半目标从低点算起变成
|
||||
了 4.7 ATR。胜率 15~21% 完美对应这个几何:**它低于随机入场**(SL2/TP3 下随机
|
||||
约 40%)。
|
||||
|
||||
**这和三类的滞后代价不是一回事。** 三类是顺势突破,等 8 根是「追高一点」;
|
||||
一类是逆势抄底,等 8 根是「在反弹了 1.7 ATR 之后才去接刀」——**同样的滞后,
|
||||
在反转型信号上是加倍惩罚**。
|
||||
|
||||
**④ 结论与下一步**
|
||||
|
||||
引擎现成的 B1/B2 **不可用**,且原因是结构性的不是参数性的,调参救不回来。
|
||||
B3 当年的解法不是调参,是**重新定义成实时判据**(B4:收盘突破 zg → 收盘未跌回
|
||||
→ 重新上行,全部当根可判),选样口径随之改变(627 个中枢只认 212 个),
|
||||
统计性质才翻正。
|
||||
|
||||
**但这条路未必能照搬到一类**:B4 能实时判,是因为「突破 + 回踩不破 + 重新上行」
|
||||
每一步都能用收盘价当根判定。而一类的核心是**背驰**,它要求「知道这一笔已经
|
||||
结束」——那本身就是事后信息。要做实时版,得先找到一个不依赖笔端点的力竭代理。
|
||||
|
||||
> 另有一个来自 §3.391 的先验值得先算:**一类按定义就发生在一段已走完的行情
|
||||
> 末端,也就是 `mom60` 最高的那个区间——而那正是我们实测最差的四分位**
|
||||
> (Q4 发现期余量只剩 1.44bp)。真要做实时一类,先验就不利。
|
||||
|
||||
### 3.394 实时版一类(fast B1/S1):滞后不是病根,一类没有边(step56)
|
||||
|
||||
§3.393 判定引擎 B1/S1 不可用后,用户要求照 B3→B4 的路子做实时版。做了,
|
||||
**没救回来**,但这次实验把病因锁死了。
|
||||
|
||||
`research/lib/fast_bsp1.py` 把引擎 B1 的三个条件全换成当根可判的代理:
|
||||
|
||||
| 引擎条件 | 滞后源 | 实时代理 |
|
||||
|---|---|---|
|
||||
| 中枢已成 `zs.is_sure` | 无 | `available_ts`(与 B4 同口径) |
|
||||
| 一笔向下离开中枢 | 笔端点 | **收盘** < zd |
|
||||
| 背驰 `check_bi_div` | 笔端点 | 进入段面积在中枢确认时已是历史;离开段面积从突破根逐根累加 |
|
||||
| 触发 `leave_bi.sure_time` | **主要滞后源** | 收盘反向越过前一根极值(沿用 B4 的转强判据) |
|
||||
|
||||
注意一类和三类**方向相反**:中枢向下突破后 B4 做空,fast B1 是在同一段下跌里
|
||||
找力竭然后做多。
|
||||
|
||||
**结果(5 币,5m/15m,出场 2/3/8/2/48)**
|
||||
|
||||
| 信号 | 5m 笔数 | 5m 胜率 | 5m PF | 5m t值 | 15m PF | 15m t值 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| fastB1 一买 | 482 | 22.4% | 0.38 | −9.4 | 0.37 | −10.6 |
|
||||
| fastB1 一卖 | 528 | 20.3% | 0.36 | −12.5 | 0.41 | −9.6 |
|
||||
| **fastB3 三买**(同管线对照) | 972 | **55.0%** | **1.83** | **+10.6** | 1.84 | +8.5 |
|
||||
| **fastB3 三卖**(同管线对照) | 927 | **54.6%** | **1.93** | **+10.5** | 2.06 | +10.7 |
|
||||
|
||||
**对照组是这次实验的全部价值所在**:同一份数据、同一套出场、同一条管线,
|
||||
B4 跑出 PF 1.83~2.06 / t +8.5~+10.7。所以 fast B1 的负数不是管线接错了。
|
||||
|
||||
**病根锁定:滞后不是主因。** 引擎版 PF 0.22 → 实时版 0.38,确实有改善,
|
||||
但离 1.0 还差得远。更关键的是滞后分档:
|
||||
|
||||
| lag(入场根 − 极值根) | 5m 笔数 | 5m PF |
|
||||
|---|---|---|
|
||||
| ≤1 根(≈买在极值上) | 472 | 0.40 |
|
||||
| 2-3 根 | 390 | 0.36 |
|
||||
| 4-6 根 | 125 | 0.29 |
|
||||
|
||||
**即使入场就在极值那一根,PF 仍只有 0.40。** §3.393 里「等 8 根导致几何劣势」
|
||||
的解释因此只对了一半:滞后确实在扣分,但**把滞后清零也救不活**——一类在这个
|
||||
市场/周期/出场结构下本身就是负边缘的。
|
||||
|
||||
背驰强度有单调性但不够:15m 上最强四分位 PF 0.99、余量 −0.24bp、t −2.0,
|
||||
毛收益刚够打平、净收益仍为负;且这是四选一的样本内挑选,不构成可交易的结论。
|
||||
|
||||
**判定:一类到此为止,不再投入。** 这与 §3.4 消融、§3.391 的 `mom60` 结论
|
||||
互相印证——这一族信号的 alpha 在**顺势延续**上,不在反转上。fast_bsp 的文档
|
||||
头早就写了「B4 不只是 B3 提前几根,两者统计性质符号相反」,一类的实验说明
|
||||
反过来也成立:**把反转信号提前,它还是反转信号。**
|
||||
|
||||
### 3.395 ⛔ 一类反手:**已被 step59 推翻,不要用本节的 PF**(step55/57)
|
||||
|
||||
> **本节结论作废。** 下面的 PF 2.3~3.0 用 `sure_time+1` 当入场时点,而 §3.396
|
||||
> 的逐根回放证明实盘要等中位 **+15 根**才知道信号存在(0% 能准时拿到),
|
||||
> 按真实首现根入场后 PF 塌到 0.92 / 1.30、t 0.20 / 0.64。
|
||||
> 保留本节是为了记录推理链和那个「胜率低于随机 = 信号指反了」的判据,
|
||||
> 它本身是对的、也可复用;错的是把 `sure_time` 当成了可执行时刻。
|
||||
|
||||
|
||||
|
||||
用户问「是不是一类二类的识别错了」。查下来结论是:**代码没写错,但信号该反着用。**
|
||||
|
||||
**先排除掉一个我自己的错误猜测。** `check_bi_div` 只比 MACD 面积、不检查创新极值,
|
||||
这是缠论背驰定义(创新极值 **且** 力度减弱)丢了一半。看着像致命缺陷,但实测
|
||||
**93.2% 的 B1 / 93.4% 的 S1 离开笔本来就创了新极值**,缺口只影响约 7%,
|
||||
解释不了 PF 0.2。**先量再断,别拿理论缺陷当结论。**
|
||||
|
||||
**真正的线索是胜率的方向**:B1 胜率 18.6%,而 SL2/TP3 下随机入场约 40%。
|
||||
**远低于随机说明样本带信息,只是指反了**——真正没用的信号会落在 40% 附近。
|
||||
于是拿相反方向重跑出场(路径依赖,不能把 g 取负号了事):
|
||||
|
||||
| 反手 | 5m 胜率 | 5m PF | 5m t值 | 15m PF | 15m t值 |
|
||||
|---|---|---|---|---|---|
|
||||
| **B1(反手做空)** | 64.4% | **2.35** | +10.4 | **3.03** | +12.6 |
|
||||
| **S1(反手做多)** | 67.1% | **2.75** | +12.8 | 2.63 | +9.6 |
|
||||
| B3/S3(反手) | 45~49% | 1.14~1.24 | +4 | 1.15~1.20 | +4 |
|
||||
|
||||
**稳健性(step57)——这是本项目做过的最稳的结果**
|
||||
|
||||
| 切法 | 结论 |
|
||||
|---|---|
|
||||
| 逐币 | 5 个币全正。15m PF 2.25~4.12,5m 2.31~2.70 |
|
||||
| 前后半段 | 15m 2.81 / 2.81,5m 2.55 / 2.51,几乎不变 |
|
||||
| 逐年 | 2021~2026 **连续六年全正**,15m PF 2.57~2.99 |
|
||||
| 多空 | 两边都正,排除单边行情 |
|
||||
| 余量 | 15m 33~76bp、5m 18~28bp。对比 B4 只有约 3bp |
|
||||
|
||||
**为什么不是 bug 而要反着用**
|
||||
|
||||
代码是照缠论写的:向下离开中枢 + 背驰 → B1 买。**教科书没错,错的是它发信号的
|
||||
时刻。** 信号在 `leave_bi.sure_time` 发出,中位滞后 8~9 根,此时价格已从低点
|
||||
**反弹了 1.68 ATR**(§3.393 表)。引擎想说「抄底」,但它给出的时间戳对应的是
|
||||
「反弹已走完」。在下跌趋势里于此处做空 —— 那是标准的**顺势回调入场**。
|
||||
|
||||
所以这一族信号的几何识别是对的,方向标注按理论也是对的,**是滞后把它变成了
|
||||
相反的交易**。它是一个穿着反转外衣的顺势回调信号。
|
||||
|
||||
**它与 B4 不是一回事**:B4 是突破延续(§3.391 实测 mom10 中位 +3.47 ATR,
|
||||
根本不是回调入场),这个是回调延续。两种入场原型不同,**可能互补**。
|
||||
|
||||
**止损位置:真问题,但不是主因(step58)**
|
||||
|
||||
用户提出:B1 做多的止损可能挂在了 B2 低点上方,于是在那次「回踩不破前低」的
|
||||
正常回抽处被打掉,而不是等趋势真正走完。查下来**观察成立且比预估更严重**:
|
||||
|
||||
| 量 | 5m 一类 |
|
||||
|---|---|
|
||||
| 入场价到结构极值(ATR,用入场根 ATR 归一) | 中位 **2.90** |
|
||||
| 2ATR 止损在结构位外侧留的余地 | 中位 **−0.90**(在结构**以内**) |
|
||||
| **止损落在结构位以内的占比** | **88.6%** |
|
||||
|
||||
即近九成的一类做多,价格连回踩到前低都不需要就已出局。
|
||||
|
||||
修掉之后确实有改善,但救不活。⚠️ 做这个对照时目标必须**随止损等比放大**
|
||||
(恒定 1.5R 减半 / 4R 收尾);放宽止损却不放大目标会把盈亏比压到 1 以下,
|
||||
测出来的「放宽无效」是自证的:
|
||||
|
||||
| 方向 | 止损 | 中位宽度 | 胜率 | PF | t值 |
|
||||
|---|---|---|---|---|---|
|
||||
| 原方向做多 | 固定 2ATR | 2.00 | 16.7% | 0.22 | −31.0 |
|
||||
| 原方向做多 | 结构+1.5 | 4.40 | 33.1% | **0.36** | −16.6 |
|
||||
| 反手做空 | 固定 2ATR | 2.00 | 65.7% | **2.53** | +14.4 |
|
||||
| 反手做空 | 结构+1.5 | 4.40 | 58.2% | 2.15 | +10.7 |
|
||||
|
||||
**结论:趋势确实越过第二类买卖点继续走原方向。** 最有说服力的读法是
|
||||
「结构+1.5」那一档——止损宽达 4.4 ATR,仍有 **67% 被打掉**,即三分之二的一类
|
||||
信号会在 48 根内跌破结构低点再多走 1.5 ATR。缠论里「B2 回踩不破前低」这条
|
||||
在本市场多数时候不成立。
|
||||
|
||||
佐证:反手做空在**最窄的 2ATR 止损上最好**(2.53),放宽反而降到 2.15。
|
||||
这与顺势回调入场自洽——价格若重新涨过反弹高点,论点当场失效,不该留余地。
|
||||
|
||||
**仍未验证,落地前必须做完(否则不许上实盘)**
|
||||
|
||||
1. **因果性**。`zs.is_sure` / `bi.sure_time` 在全量数据上算出,§5.41 已知中枢
|
||||
右边缘会重画。必须用增量/流式回放确认信号在当时真的发得出来。
|
||||
*反证据(不能替代验证)*:同一套机器算出的 B3 是 PF 0.64 的烂数,
|
||||
若存在通用的未来函数,B3 不该这么难看。
|
||||
2. **消融:是不是「反弹就做空」都赚**。照 §3.4 对 B4 做过的那样,把背驰条件
|
||||
拿掉、把中枢换成伪阻力位,看 PF 掉不掉。掉不下来就说明 alpha 在几何和
|
||||
时点上,与背驰无关,那这个信号就该重新命名和重新设计。
|
||||
3. **与 B4 的重叠度与相关性**。若只是换名字的 B4,就没有增量价值。
|
||||
|
||||
### 3.396 ⭐ 因果回放:信号存在性是真的,时点是假的(step59)
|
||||
|
||||
§3.395 的 PF 太好,先查因果性——增量模块文件头自己写着「最后一笔 is_sure
|
||||
**允许收回**」,§5.41 也记了中枢右边缘会重画。
|
||||
|
||||
**做法**:`init_stream` 预热 2 万根,随后逐根 `append_bar`,每根之后重算
|
||||
`cal_bi_zs_list_pure` + `find_all_bsp`,记录每个信号**第一次出现**在哪一根;
|
||||
入场用首现根,而不是事后的 `sure_time`。信号身份用「类型 + 极值 KLC 时刻」,
|
||||
**不能用 `sure_time` 当键**——它正是会被重画的字段。
|
||||
|
||||
必须逐根。分段重建(每 S 根算一次)等于在第 t 根多给了 S 根的信息,
|
||||
测出来的因果性是假的。
|
||||
|
||||
| 检查 | 结果 |
|
||||
|---|---|
|
||||
| 召回(全量信号在回放中出现过的比例) | **100%**(B1 50/50,S1 65/65) |
|
||||
| 幻影(回放发过、全量没有的) | **0** |
|
||||
| 首现根 − 全量 `sure_time` | 中位 **+15 根**,P25 +11,P75 +22,P90 +31 |
|
||||
| **准时拿到的比例** | **0%** |
|
||||
|
||||
| 反手 · 5m | 全量口径 | **回放首现口径** |
|
||||
|---|---|---|
|
||||
| B1 | PF 2.35 | **0.92**(t 0.20,余量 −1.37bp) |
|
||||
| S1 | PF 2.75 | **1.30**(t 0.64,余量 +4.21bp) |
|
||||
|
||||
**结论**:信号的**存在性**是因果的(不重画、不消失),但**时点**不是。
|
||||
实盘要晚中位 15 根、5m 上 75 分钟才知道它存在,而那笔交易吃的正是
|
||||
「反弹已走完」这个窗口,晚 75 分钟就没了。t 值 0.20/0.64 完全不显著。
|
||||
|
||||
⚠️ **可复用的教训**:`sure_time` 是引擎事后标注的确认时刻,**不等于可执行时刻**。
|
||||
任何用它当 entry 的回测都要先过逐根回放。样本 115 笔偏薄,但机制清楚、
|
||||
点估计从 2.35 塌到 0.92,方向不存在疑义。
|
||||
|
||||
### 3.397 线段顶点当标准答案:识别是对的,但精度和时点都不够(step60)
|
||||
|
||||
用户提出用线段终点当趋势反转的标准答案——是未来函数,但用作**标签**验证
|
||||
检测器而非用于交易,能把「检测器对不对」和「能不能交易」分开。
|
||||
|
||||
**对照组是关键**:B1 按构造就长在笔的低点上,而笔低点本来就有概率撞上线段底。
|
||||
所以问的是「在所有同向笔端点里,B1 这个标签把命中率提高了多少倍」。
|
||||
|
||||
| 5m | 样本 | 命中线段顶点 | 相对笔端点的提升 |
|
||||
|---|---|---|---|
|
||||
| 笔端点(基准) | 48229 | 15.0% | 1.00 |
|
||||
| **B1** | 623 | **30.3%** | **2.02×** |
|
||||
| **S1** | 644 | 27.2% | 1.81× |
|
||||
| B3 / S3 | 2806 | **0.0%** | 0.00 |
|
||||
|
||||
15m 同向:B1 28.1%(1.81×)、S1 27.3%(1.76×)。
|
||||
|
||||
各容差档(±0~±5 根)数字完全相同,不是 bug:线段终点**本身就是某根笔的终点**,
|
||||
两者天然按笔端点对齐,放宽容差不会多命中。B3/S3 恰好 0% 也是对的——
|
||||
三类长在回抽笔的端点上,按定义就在趋势中段,不该命中反转点。这两条互为
|
||||
标签有效性的旁证。
|
||||
|
||||
**所以检测器是有信息量的:它把「这个笔低点是真反转」的概率翻了一倍。**
|
||||
但还有两道坎:
|
||||
|
||||
| 5m · 按原方向抄底 | 笔数 | 胜率 | PF | t值 |
|
||||
|---|---|---|---|---|
|
||||
| **未命中**线段顶点 | 903 | 8.7% | **0.08** | −42.0 |
|
||||
| **命中**线段顶点 | 364 | 36.3% | **0.70** | −3.2 |
|
||||
|
||||
15m:未命中 PF 0.08、命中 PF 0.83(t −0.36)。
|
||||
|
||||
**精度只有 30%,而那 70% 的噪声是灾难性的(PF 0.08)。更要命的是:即使用
|
||||
未来函数把精度提到 100%,也只到 PF 0.70~0.83,仍不赚钱。**
|
||||
|
||||
原因是 §3.398 的几何:入场价已在结构底上方 **2.90 ATR**(step58)。
|
||||
即使你真站在线段底上,从底部上方 2.9 ATR 处用 2 ATR 止损做多,结构本身就是
|
||||
负期望的。§3.396 的回放还要在此之上再晚 15 根。
|
||||
|
||||
**一类的完整诊断(三层,逐层否定)**
|
||||
|
||||
1. 识别对不对 —— **对**,2× 提升(step60)
|
||||
2. 精度够不够 —— **不够**,30%;且用未来函数选到 100% 也只到 PF 0.83
|
||||
3. 时点来不来得及 —— **来不及**,全量口径已晚到入场价高出结构底 2.9 ATR,
|
||||
实盘回放再晚 15 根
|
||||
|
||||
**判定:一类线到此为止。** 病不在识别,在于「等笔确认」这个机制天然把信号
|
||||
推到了结构失效之后,而这是 `find_all_bsp` 的固有属性,不是可调的参数。
|
||||
|
||||
### 3.398 极值点的波动率:观察成立,但解释力有限(step61)
|
||||
|
||||
用户提出:一二类长在极值点上,那个区域波动天然大,这可能就是确认慢的原因。
|
||||
拆成三条可证伪的子命题分别测,结论是**一条成立、一条只沾边、一条不成立**。
|
||||
|
||||
**① 一类确实长在高波动区 —— 成立,且很干净**
|
||||
|
||||
`atr_z` = 该点 ATR / 其**之前** 200 根的中位 ATR(含当根会把要检验的插针算进去)。
|
||||
|
||||
| 5m | 样本 | atr_z 中位 | 高于基准占比 |
|
||||
|---|---|---|---|
|
||||
| 笔端点(基准) | 48219 | 1.003 | **50%** |
|
||||
| **B1** | 623 | **1.224** | **74%** |
|
||||
| **S1** | 644 | 1.173 | 68% |
|
||||
| B3 / S3 | 2806 | 1.03~1.06 | 55~56% |
|
||||
| **B2 / S2** | 293 | **0.94~0.97** | **39~44%** |
|
||||
|
||||
基准恰好落在 50%,说明构造是对的。一类比随机笔端点显著偏高。
|
||||
|
||||
⚠️ **但二类是反过来的**:B2/S2 的 atr_z 中位 < 1,只有约四成高于基准 ——
|
||||
**二类长在低波动区**。这符合结构:二类是首轮反转冲动之后的回抽,那时波动已
|
||||
平复。所以「一二类都在极值点」这个说法对一类成立、对二类不成立,
|
||||
两者是镜像而非同类。
|
||||
|
||||
**② 波动大 → 确认慢 —— 只沾边,不是主因**
|
||||
|
||||
| 5m · atr_z 四分位 | atr_z 中位 | 滞后中位 | 滞后均值 |
|
||||
|---|---|---|---|
|
||||
| Q1 低 | 0.85 | 8.0 | 9.3 |
|
||||
| Q2 | 1.07 | **7.0** | 8.8 |
|
||||
| Q3 | 1.34 | 8.0 | 9.6 |
|
||||
| Q4 高 | 1.80 | 9.0 | 11.3 |
|
||||
|
||||
`corr(atr_z, lag_bars) = +0.10`(15m 亦为 +0.107)。方向对,但只解释约 1% 的
|
||||
方差,滞后中位几乎不随波动变化。**滞后是结构性的**——笔要等分型形成再确认,
|
||||
这个 8~9 根跟波动率基本无关(§3.393 已测得三类买卖点滞后完全一致)。
|
||||
|
||||
**③ 入场后波动率回落 → 目标够不着 —— 回落属实,但不是亏损的机制**
|
||||
|
||||
`atr_fwd` = 入场后 48 根平均 ATR / 入场时 ATR:一类 5m 中位 **0.896**(69% 回落),
|
||||
三类 0.945~0.957。即一类的 3/8 ATR 目标按一个虚高的 ATR 定,绝对价格上约高估
|
||||
10%。回落是真的。
|
||||
|
||||
**但它不是死因**:
|
||||
|
||||
| 5m | 走到 3ATR 减仓 | 止损 | 超时 | 止盈 |
|
||||
|---|---|---|---|---|
|
||||
| **一类** | **9.4%** | **81.3%** | 17.9% | 0.8% |
|
||||
| 三类 | 30.7% | — | — | — |
|
||||
|
||||
**九成的一类根本走不到第一个目标,八成直接止损出场。** 若死因是「目标够不着」,
|
||||
超时占比该很高;实际超时只有 17.9%。所以一类不是「走不动」,是**入场后立刻
|
||||
朝反方向走**。
|
||||
|
||||
按 atr_z 分档看表现也印证:PF 在 0.17~0.25 之间基本持平,高波动组并不更差
|
||||
也不更好 —— 波动率**不是**区分变量。
|
||||
|
||||
**小结**:极值点高波动这个观察是对的,但它既没造成滞后(结构性的),也没造成
|
||||
亏损(死于立即反向而非目标够不着)。它与 §3.397 的结论一致:一类的入场价已在
|
||||
结构底上方 2.9 ATR,那个位置本身就是负期望的。
|
||||
|
||||
### 3.399 ⛔ 一类线收尾:过滤器符号随入场时点翻转,判定为拟合(step62/56)
|
||||
|
||||
用户指出:很多一二类实际在**趋势中途**被识别而非末期;若真在末期,即使有延迟
|
||||
也该走出行情。step60 的数据支持这个判断——命中线段顶点的 30% 是 PF 0.70~0.83,
|
||||
**没命中的 70% 是 PF 0.08**,亏损几乎全部来自误识别在趋势中途的那批。
|
||||
|
||||
**step62:找到了区分变量,识别精度接近翻倍**
|
||||
|
||||
`ext_run`(极值越过中枢边界几个 ATR)单调有效,5m:
|
||||
|
||||
| ext_run 档 | 命中线段顶点 | PF |
|
||||
|---|---|---|
|
||||
| Q1 最短 | **12.0%** | 0.12 |
|
||||
| Q4 最延伸 | **43.8%** | 0.46 |
|
||||
|
||||
最佳组合 `ext_run≥P75 且 div≥中位`:命中率 47.9%、PF 0.54
|
||||
(基准 28.7% / 0.22)。**识别确实可以优化,且幅度不小。**
|
||||
|
||||
三个附带结论:
|
||||
|
||||
- **背驰越强反而越差**:div Q1(最强)命中 17.0% / PF 0.13,Q4(最弱)
|
||||
37.5% / 0.28。这是对 `check_bi_div` 这个 MACD 面积判据的直接证伪。
|
||||
- **趋势级数无区分力**:连续同向中枢数 ≤1/2/3/≥4 的命中率
|
||||
28.5/30.6/28.6/25.5%,基本持平。
|
||||
- **「趋势 vs 盘整」前提引擎已隐含强制**(纠正我先前的猜测):B1 要求
|
||||
`enter_bi.dir == leave_bi.dir == DOWN`,中枢向下进、向下出本身就定义了它嵌在
|
||||
下跌趋势里,实测该条件在 2504 笔上**恒为 True**,过滤器无从添加。
|
||||
`zs_count` 也不可用——它是全局中枢序号(310、585)而非趋势内序号。
|
||||
|
||||
**step56 复测:把过滤器接到低滞后版上,符号翻转**
|
||||
|
||||
step56 的实时版把滞后压到 ≤1 根但无质量过滤(PF 0.40),step62 有过滤但滞后
|
||||
未解(PF 0.54)。两者各解决一半,合起来是唯一同时处理两个约束的路径。结果:
|
||||
|
||||
| 5m · 延伸度分档 | 滞后版(step62) | 低滞后版(step56) |
|
||||
|---|---|---|
|
||||
| Q1 最短 | 0.12 | **0.58** |
|
||||
| Q4 最延伸 | **0.46** | 0.33 |
|
||||
|
||||
叠加后 PF 从 0.37 掉到 0.30~0.33,**比不过滤更差**。`div` 同样翻转:滞后版里
|
||||
背驰最弱的最好,低滞后版里背驰最强的最好。
|
||||
|
||||
**这个符号翻转是判定依据,不是需要再调的参数。** 同一批信号、同一个特征,
|
||||
换个入场时点最优方向就反过来,说明这些「过滤效果」是入场时点的交互产物,
|
||||
不是信号的稳定属性。继续在上面挑阈值就是拟合噪声。
|
||||
|
||||
**一类线全部尝试汇总(六次独立进攻,全部止步于 1.0 以下)**
|
||||
|
||||
| 尝试 | 最好 PF | 出处 |
|
||||
|---|---|---|
|
||||
| 引擎原生 | 0.20~0.24 | step55 |
|
||||
| 反手 | 2.35~3.03 → **0.92/1.30** 因果回放后 | step57 / **step59** |
|
||||
| 实时重写(fast_bsp1) | 0.36~0.41 | step56 |
|
||||
| 结构止损(目标等比放大) | 0.36 | step58 |
|
||||
| 线段顶点选样(**用未来函数**) | 0.70~0.83 | step60 |
|
||||
| 质量过滤(ext_run + div) | 0.54 | step62 |
|
||||
| **低滞后 + 质量过滤** | **0.33** | step56 |
|
||||
|
||||
注意第五行:**即使用未来函数把选样做到完美,也只到 0.83。**
|
||||
|
||||
**判定:一类线关闭。** 病根是 §3.397 的几何——入场价已在结构底上方 2.9 ATR,
|
||||
实盘回放还要再晚 15 根(§3.396),而这来自「等笔确认」这个机制本身,
|
||||
不是可调参数。识别可以优化,但识别从来不是瓶颈。
|
||||
|
||||
### 3.3991 ⛔ 二类:不是继承了一类的错,是信息为零(step64)
|
||||
|
||||
用户提的悖论:二类按定义是「一买后回调、再继续趋势」,一类既已证否,二类凭什么好?
|
||||
|
||||
代码上悖论成立——`find_all_bsp` 里 B2 被 B1 门控(`if first_bsp_bi_div`)。但当时
|
||||
有个反驳值得测:B2 比 B1 多要求价格**确实反弹了**、且回踩**守住了** B1 低点,
|
||||
这两条是「那个底是真的」的事后确认,而 §3.397 的诊断恰恰是一类缺这个确认。
|
||||
若成立,B2 就是 B1 里被验证过的子集,是好事而非坏事。
|
||||
|
||||
**测下来是坏消息,且机制与用户说的不同。**
|
||||
|
||||
⚠️ 先记一个**我用错的口径**:报表里 B2/S2「命中线段顶点 0.0%」(276~293 笔一个
|
||||
没中)**是定义决定的,不是发现**。B2 按定义是反弹后的更高低点,线段顶点在 B1
|
||||
那个极值上,两者不可能重合。这个指标对自称在极值的一类有效,对二类无效。
|
||||
若日后要给二类找标准答案,该问的是「B2 之后 B1 低点是否再未被破」,不是顶点重合。
|
||||
|
||||
两组有效结论:
|
||||
|
||||
**一、二类的几何是所有信号里最差的**
|
||||
|
||||
| | 入场到结构止损位 | 2ATR 止损落在结构内侧 |
|
||||
|---|---|---|
|
||||
| B1 | 2.90 ATR | 88.6% |
|
||||
| **B2** | **4.87 ATR** | **99~100%** |
|
||||
|
||||
二类的天然止损位是 B1 的低点(回踩守住的就是那点)。但反弹 + 回踩之后,入场价
|
||||
离那个低点已有 4.87 ATR。**二类没有可用的止损位**:挂 2 ATR 是纯任意止损,
|
||||
挂到结构位则单笔风险近 5 ATR。step58 在一类上试过放宽到结构位,PF 仅 0.22→0.36;
|
||||
二类的缺口比一类大 68%,没有理由更好。
|
||||
|
||||
**二、二类两个方向都没有边缘——这是它和一类的本质区别**
|
||||
|
||||
| 5m/15m | 原方向 PF / t | 反手 PF / t |
|
||||
|---|---|---|
|
||||
| B1 | 0.21~0.24 / **−17 ~ −19** | 2.35~3.03 / **+10 ~ +13** |
|
||||
| S1 | 0.20~0.23 / **−16 ~ −22** | 2.63~2.75 / **+9.6 ~ +12.8** |
|
||||
| B2 | 0.73~0.85 / −0.79 ~ −1.42 | 1.09~1.10 / +1.67 ~ +1.70 |
|
||||
| S2 | 0.75~0.82 / −1.55 ~ −2.40 | 0.85~1.13 / +0.19 ~ +1.36 |
|
||||
|
||||
**一类 t = −17~−22 是强烈指反**:它有信息,只是滞后把「反转信号」变成了
|
||||
「反弹追单」(反手曾看似 PF 3.0,但 §3.396 已证那是 `sure_time` 后视产物)。
|
||||
**二类两边 t 都贴着零**,原方向 −0.8、反手 +1.7,5m 反手 S2 甚至 0.85。
|
||||
没有可提取的东西,连反手都没有。
|
||||
|
||||
其余画像与 §3.393 一致:二类 atr_z 中位 0.94~0.97(低波动,与一类的 1.22 相反),
|
||||
滞后 8 根(与一类持平,§3.393 已记「三类滞后无差别」)。
|
||||
|
||||
**判定:二类关闭,一二类线整体收尾。** 一类是**信号指反**(有信息、方向错、
|
||||
错在滞后),二类是**信号消失**——在一类之上又等两笔,把仅剩的方向性磨掉,
|
||||
同时把止损位推远到 4.87 ATR。用户的悖论方向对,但不是「继承错误」,
|
||||
是「多等的那两笔把信息也等没了」。
|
||||
|
||||
### 3.3992 ⭐ 一类线重开:病根是选样×止损的交互,不是没机会(step65/58/66)
|
||||
|
||||
用户问「那如果修改 B1/B2 的识别规则呢」。这个问题**推翻了 §3.399 的关闭结论**,
|
||||
下面三步是重开的依据。
|
||||
|
||||
**step65:先量机会本身装不装得下确认成本(不经过检测器,直接取 `seg_list`)**
|
||||
|
||||
| 级别 | 线段中位幅度 | 扣 2.9 ATR 确认成本后 | 剩余 > 2ATR 止损 | 剩余/止损 |
|
||||
|---|---|---|---|---|
|
||||
| 5m | 11.4 ATR | 8.5 | **93%** | **4.26x** |
|
||||
| 15m | 10.9 | 8.0 | 93% | 3.99 |
|
||||
| 1h | 11.5 | 8.6 | 95% | 4.30 |
|
||||
| 4h | 12.4 | 9.5 | 96% | 4.74 |
|
||||
|
||||
五币四级别一致(10.5~13.7)。**滞后不是瓶颈**——这与我原先的预期相反。
|
||||
但它与 §3.397 直接矛盾(空间在,却拿不到),矛盾的解答就是下面这条。
|
||||
|
||||
**step58 复测:先排除一个伪解释。** 首轮结构止损用了 `r_scale=True`,
|
||||
3.9 ATR 的止损把 runner 目标推到 15.6 ATR,比 11.4 ATR 的整段行情还长。
|
||||
改成绝对 3/8 ATR 目标重跑,PF 仍是 0.36(与 r_scale 版**完全相同**)。
|
||||
**我预判的这个修正不成立**,首轮结论正确只是理由错了。
|
||||
|
||||
**step66:把两个杠杆同时拧(三者首次组合),天花板一次性打开**
|
||||
|
||||
| 5m | 固定 2ATR | 结构+1.0 |
|
||||
|---|---|---|
|
||||
| 落在真线段端点(28.7%) | PF **0.70** / 胜率 36% | PF **2.29** / 胜率 63% |
|
||||
| 没落在(71.3%) | 0.08 | 0.14 |
|
||||
|
||||
15m:真底那批 **0.83 → 4.11**,胜率 44% → 70%。
|
||||
|
||||
**这是交互效应,不是叠加**——前三次进攻各自只动了一半,所以全部失败:
|
||||
|
||||
| 只做完美选样(step60) | 只放宽止损(step58) | **两个一起(step66)** |
|
||||
|---|---|---|
|
||||
| 0.70 / 0.83 | 0.36 | **2.29 / 4.11** |
|
||||
|
||||
MFE/MAE 给出机制:真底那批**逆向行程中位 2.24 ATR(5m)/ 2.08(15m)**,
|
||||
即 2 ATR 止损**打掉了 51~58% 的好单**;而非真底那批逆向行程中位 4.76/4.93,
|
||||
放宽只是亏更多。**固定 2 ATR 同时做错两件事:杀掉对的单,又留不住错的单。**
|
||||
|
||||
⚠️ **这个 2.29/4.11 里有两个未来函数,不是策略,是上界**:
|
||||
(a) 选样用了线段端点;(b) 入场仍在 `sure_time`,而 §3.396 证明实盘还要再晚 15 根。
|
||||
它证明的是**失败原因可修(选样精度 + 止损位),而非这个级别没得做**。
|
||||
|
||||
**由此产生的两个待跑实验:**
|
||||
1. ✅ **实时过滤器 + 结构止损** → 见 §3.3993,兑现不了,但换来一个确切门槛
|
||||
2. **超时从 48 根放宽**(仍未跑)。线段中位时长 **113 根**,48 根超时在行情走完
|
||||
42% 时就平仓;真底那批 MFE 中位仅 2.37 ATR,可能是被截断的(而非行情本身小)
|
||||
|
||||
### 3.3993 ⭐ 一类线的门槛量化:全局只有一个变量,精度要 67~73%(step67)
|
||||
|
||||
把 §3.3992 的天花板换成**实时可算**的 `ext_run` 选样,配结构止损:
|
||||
|
||||
| | 5m | 15m |
|
||||
|---|---|---|
|
||||
| 全体无过滤 | 0.34 | 0.32 |
|
||||
| ext_run ≥ P75(实时) | 0.51 | 0.48 |
|
||||
| ext_run≥P75 且 div≥中位 | 0.60 | 0.46 |
|
||||
| ★ 真线段端点(未来函数) | **2.29** | **4.11** |
|
||||
|
||||
**兑现不了。** 但数字对不上——Q4 的真端点浓度已达 44%(基线 28.7%),
|
||||
若真端点值 2.29,44% 浓度不该只有 0.51。拆开看:
|
||||
|
||||
| ext_run 档 | 真端点组 PF (5m / 15m) | 非真端点组 PF (5m / 15m) |
|
||||
|---|---|---|
|
||||
| Q1 最短 | 2.53 / 2.61 | 0.18 / 0.11 |
|
||||
| Q4 最延伸 | 2.22 / **6.10** | 0.16 / 0.12 |
|
||||
|
||||
**这张表是本节的结论**:`ext_run` 在真端点**内部**毫无反向选样(5m 持平、
|
||||
15m 单调递增到 6.10),在非真端点内部也毫无区分力(恒在 0.11~0.18)。
|
||||
**全局只有「是不是真端点」这一个变量在起作用**,其余特征都是它的噪声代理。
|
||||
|
||||
这同时**修正了 §3.399 的判定**:当时把 `ext_run` 的符号翻转判为拟合。
|
||||
现在看它不是拟合——它确实提纯(28.7% → 44%),只是提纯幅度远远不够。
|
||||
|
||||
于是 PF 退化成两组按精度 p 的混合,可解出门槛:
|
||||
|
||||
| 实时精度 | 28.7%(当前) | 44%(ext_run) | 60% | 70% | 100%(上界) |
|
||||
|---|---|---|---|---|---|
|
||||
| PF (5m) | 0.34 | 0.50 | 0.73 | 0.93 | 2.29 |
|
||||
| PF (15m) | 0.33 | 0.51 | 0.81 | 1.10 | 4.11 |
|
||||
|
||||
**盈亏平衡精度:5m 需 72.6%,15m 需 67.1%。** 当前 28.7%,`ext_run` 做到 44%。
|
||||
|
||||
**判定:一类线的问题已完全定性,但门槛极高。** 要把「这是不是真线段底」的实时
|
||||
判准精度做到 ~70%(现在 28.7%,最好的特征到 44%),本质上是要求实时抄底摸顶的
|
||||
准确率达到七成。这是可以尝试的 ML 问题(标签、特征、评估口径本节都已给全),
|
||||
但**先验上很难**,且 70% 只是毛平衡,还要再扣 §3.396 的 15 根因果滞后。
|
||||
|
||||
> **这解释了为什么 B4 能做而一类不能**:B4 是突破后的**延续**信号,
|
||||
> 不需要判断反转;一类的全部难度集中在「这是不是那个底」这一个二分类上。
|
||||
|
||||
### 3.3994 🚨 B4 因果回放:时点干净,但实盘做的不是回测那批单(step63/68)
|
||||
|
||||
**这条影响正在跑的实盘,优先级高于本文档其余全部内容。**
|
||||
|
||||
**好消息:B4 的时点完全干净,一类那个坑它没有**
|
||||
|
||||
| | B1(§3.396) | **B4** |
|
||||
|---|---|---|
|
||||
| 召回 | 100% | 100% |
|
||||
| **准时率(首现==entry_idx)** | **0%** | **100%** |
|
||||
| 迟到中位 | +15 根 | **0 根** |
|
||||
|
||||
**坏消息:实时回放会多产出一批全量口径里不存在的信号,且它们显著亏钱**
|
||||
|
||||
按**实盘口径**(2001 根滚动窗口、每 500 根 `init_stream` 重建、只做当根收盘,
|
||||
逐行对齐 `shadow_signal.py`)重放 5 币 × 20000 根:
|
||||
|
||||
| 过滤 | 留下 | **额外占比** | 全部 PF | 额外 PF | 回测口径 PF |
|
||||
|---|---|---|---|---|---|
|
||||
| 无过滤 | 789 | 75% | 0.64 | 0.46 | 1.37 |
|
||||
| 仅同向 | 402 | 77% | 0.65 | 0.35 | 2.65 |
|
||||
| 仅阶梯 | 341 | 68% | 0.71 | 0.31 | 2.14 |
|
||||
| 仅 ATR 门控 | 721 | 76% | 0.64 | 0.46 | 1.35 |
|
||||
| **三道全开** | **195** | **74%** | **0.73** | **0.26** | **4.21** |
|
||||
|
||||
**三道滤网挡不住这批信号。** 它们把成交量砍掉 75%(789 → 195),但额外信号占比
|
||||
从 75% 只降到 74%——按同样比例刷掉好的和坏的。实盘口径拆开:
|
||||
|
||||
| 分组 | 笔数 | 胜率 | PF | 余量bp | t值 |
|
||||
|---|---|---|---|---|---|
|
||||
| 回测里也有的 | 51 | 74.5% | **4.21** | +30.15 | +5.90 |
|
||||
| **回测里没有的** | **144** | 22.9% | **0.26** | −14.25 | **−6.21** |
|
||||
| **实盘实际会做的** | 195 | 36.4% | **0.73** | −4.65 | −0.59 |
|
||||
|
||||
**结论:回测报的 4.21 是拿不到的。** 那 51 笔只有事后用全量历史重算才能识别;
|
||||
实盘在当下拿到 195 个信号,**没有任何办法分辨哪 51 个是真的**。机制是重画——
|
||||
实时算出的中枢,数据变多后被修正掉,信号随之消失。
|
||||
|
||||
这与 §3.396 是同一类错误的两种表现:一类错在**时点**(信号存在但晚 15 根),
|
||||
B4 错在**存在性**(信号当根就有,但其中 74% 事后会消失)。
|
||||
|
||||
**顺带闭掉一条挂了很久的待办:窗口长度不是问题。** 2001 根滚动窗口与
|
||||
2 万→4 万根增长窗口跑出**完全相同**的 789/197/592,中枢是局部结构,截断无影响。
|
||||
§5.41 那条「9.0% 中枢消失是不是窗口左边界效应」可以判为**否**。
|
||||
|
||||
**1m 复验(实盘真正跑的级别):结论一致**
|
||||
|
||||
用户指出「1m 的参数和其他级别不一样」,核查后:出场参数 `SL2/3ATR减半/留损2/
|
||||
目标8ATR/48根` 两边同形状(§5.3),本轮用对了;但 **`ATR ≥ 8bp` 门控是照 1m
|
||||
标定的**,文档明确它在 5m 上惰性(触发 2.6%,「无害也无用」)。
|
||||
所以 5m 那轮的「三道全开」实际只有两道在起作用,1m 才是三道齐全。
|
||||
|
||||
| | 5m/30m | **1m** |
|
||||
|---|---|---|
|
||||
| 无过滤 | 789 笔 / 75% 额外 / PF 0.64 | 712 笔 / 77% 额外 / PF 0.74 |
|
||||
| **三道全开** | 195 / 74% / **0.73** | **122 / 72% / 0.72** |
|
||||
| 其中回测也有的 | 51 笔 PF 4.21 | 34 笔 PF **2.07** |
|
||||
| 其中额外的 | 144 笔 PF 0.26 | 88 笔 PF **0.36**(t −2.73) |
|
||||
|
||||
**ATR 门控在 1m 上确实咬得很凶(712 → 389,刷掉 45%,5m 只刷 8.6%),
|
||||
但对额外信号毫无区分力**——刷完额外占比仍是 77%,与刷之前相同。
|
||||
三道合起来砍掉 83% 的成交量,额外占比只从 77% 降到 72%。
|
||||
|
||||
⚠️ **三点限定**:
|
||||
- 1m 的回测口径只剩 **34 笔**(t 3.26),比 5m 那轮(51 笔,t 5.90)弱。
|
||||
方向一致但精度有限,**若要据此改实盘,应先扩币或延长样本**
|
||||
- 同向过滤器被**开了未来函数的后门**(大级别分型时间线用全量历史算),
|
||||
真实盘的 HTF 同样会重画,只会**更差**不会更好
|
||||
- 「额外信号」= 实时算得出、全量重算后消失的。这不等于它们在实盘一定被下单,
|
||||
还要看执行层是否有额外去重/冷却,**这一层未审计**
|
||||
|
||||
**这不否定 B4 的 alpha**——回测口径那 51 笔 PF 4.21、t 5.90 是真实的,说明
|
||||
「真中枢上的 B4」确实赚钱。问题是实时分不出真假中枢。
|
||||
|
||||
#### 3.3994a 机制订正:不是重画,是中枢终点在实时不可知
|
||||
|
||||
我最初把它归因为「中枢重画」,**这个归因是错的**,用户两次指出后逐条查证:
|
||||
|
||||
**① 中枢边界不重画(用户对)。** `zg/zd` 由前三笔定死,`verify_window_sens`
|
||||
与 step39 都验过。
|
||||
|
||||
**② 中枢只由已确认的笔构成(结构性保证)。** `cal_bi_zs_list_pure`:
|
||||
|
||||
```python
|
||||
if not (bi1.is_sure and bi2.is_sure and bi3.is_sure): continue
|
||||
if not (leave_bi.is_sure and back_bi.is_sure): break # 延伸时同样要求
|
||||
```
|
||||
|
||||
step70 实测:789 个信号**全部** `z_sure=True`。所以用户说的「浅色 B4 会出现
|
||||
然后消失」在研究路径上不存在(那来自 web 展示用的非 `_pure` 中枢)。
|
||||
⛔ **「补一道 is_sure 门」是空操作**,实测 PF 0.73 → 0.73 分毫未动,不要再提。
|
||||
|
||||
**③ 真正的机制是 `available_ts` 棘轮,§5.41 早已写明:**
|
||||
|
||||
> 6.5% 的改动**不是已确认的笔被推翻**,而是中枢又吸收了新笔、`bis[-1]` 变成了
|
||||
> 另一根笔。**原来那根笔本身没变。**
|
||||
|
||||
`available_ts` = 中枢**最后一笔**的 `sure_time`(`AVAIL_BI_INDEX = -1`),
|
||||
而中枢会持续吸收新确认的笔 → 「最后一笔」换人 → `available_ts` 往后棘轮 →
|
||||
`find_fast_bsp3` 的 200 根扫描窗口整体右移。消失的不是笔,是**中枢的终点**。
|
||||
|
||||
顺带澄清一个我一度担心的伪问题:**中枢跨度 > 200 根不会导致突破落不进窗口**,
|
||||
因为扫描起点是中枢**结束**(末笔确认)而非起点,此时价格已在离开中枢。
|
||||
|
||||
#### 3.3994b ⚠️ 订正:「额外 74%」是按条数统计的放大,按中枢是 52%(step71)
|
||||
|
||||
用户指出「原来的计算是没有问题的,是你算错了」。逐笔对账(不做统计,把两边的
|
||||
中间量全摆出来),结论一半一半。
|
||||
|
||||
**引擎没问题,我的口径也没错位**(BTC 5m,逐笔追 36 个额外信号):
|
||||
|
||||
| 归类 | 个数 | 占比 |
|
||||
|---|---|---|
|
||||
| A 棘轮(全量扫描区间不覆盖入场根) | 36 | **100%** |
|
||||
| B 中枢在全量里不存在 | 0 | 0% |
|
||||
| **C 我的 bug(区间覆盖了却没出信号)** | **0** | **0%** |
|
||||
|
||||
索引也是干净的:原始 df 5001 行 = `TF_DF.dataframe` 5001 行,逐行时间戳全部对齐。
|
||||
额外信号的中枢在全量里**全都存在且 `(zg,zd)` 完全一致**——中枢确实不重画。
|
||||
|
||||
棘轮幅度实测 32~234 根,例如入场根 2141:回放 avail 在 2104 根,
|
||||
全量 avail 已棘轮到 2299 根,全量扫描区间 [2299,2499) 根本不覆盖 2141。
|
||||
|
||||
**但同一张表暴露了我确实算错的地方:**
|
||||
|
||||
| 分组 | 信号条数 | 不同中枢数 | 每中枢触发 | 最多 |
|
||||
|---|---|---|---|---|
|
||||
| 额外 | 36 | **17** | **2.12** | 5 |
|
||||
| 全量也有 | 16 | 16 | **1.00** | 1 |
|
||||
|
||||
全量每个中枢恰好产出一个信号,回放里同一中枢平均触发 2.12 次(最多 5 次)——
|
||||
`max_per_zone=1` 只保证「每次扫描返回一个」,但扫描起点随棘轮后移,越过旧入场点
|
||||
后同一中枢重新产出「第一个」。
|
||||
|
||||
⛔ **所以 §3.3994 的「额外信号占 74%」不成立**,那是按**信号条数**统计的,
|
||||
被重复触发放大约一倍。**按中枢算是 17/33 ≈ 52%。**
|
||||
|
||||
这也把数量级对上了:§5.41 的 6.5% 是**全部中枢**里确认时刻被改的比例,而会产出
|
||||
信号的中枢里被改过的占比自然高得多——两个数不矛盾,**是分母不同,先前拿它们
|
||||
直接对比也是错的**。
|
||||
|
||||
**仍然成立**:棘轮机制、额外信号确实存在且确实亏钱(PF 0.26~0.36)。
|
||||
**已被推翻**:「实盘会多开 74% 的仓」。真实倍数取决于执行层对同一中枢是否
|
||||
去重/冷却——**这一层从未审计,它决定 2.12 这个倍数会不会落到实盘上**。
|
||||
|
||||
### 3.4 alpha 的来源(step32 消融)
|
||||
|
||||
逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位,
|
||||
@@ -2220,6 +2922,47 @@ API 限流风险隔离三个好处。
|
||||
|
||||
## 10. 待办清单
|
||||
|
||||
- [ ] 🚨 **B4 实盘口径 PF 是 0.73,回测报 4.21 —— 最高优先级**(§3.3994)。
|
||||
时点干净(100% 准时,一类那个坑没有),但实时会多产出信号,
|
||||
它们 PF 0.26 / t −6.21,**三道滤网按同比例刷除、完全挡不住**。
|
||||
⚠️ **多出的量按中枢算是 52%,不是先前写的 74%**(§3.3994b 已订正,
|
||||
74% 是按信号条数、被同一中枢重复触发放大了 2.12 倍)。**要做三件事**:
|
||||
- [ ] **⓪ 先审计执行层的去重/冷却 —— 这条现在排在最前**(§3.3994b)。
|
||||
同一中枢在实时会触发 2.12 次(最多 5 次),若执行层已按中枢或按持仓去重,
|
||||
实盘的实际多开量远小于回放,**整件事的严重程度会大幅下降**。
|
||||
在这条查清前,不要据 PF 0.73 去动实盘参数
|
||||
- [x] **① 1m 复验完成**:122 笔 / 72% 额外 / PF 0.72,与 5m 一致。
|
||||
ATR 门控在 1m 上刷掉 45%(5m 只 8.6%)但对额外信号无区分力。
|
||||
**但回测口径只剩 34 笔(t 3.26),据此改实盘前应先扩币或延长样本**
|
||||
- [ ] **①b 扩样本再确认**(改实盘的前置条件)。当前 5 币 × 2 万根,
|
||||
1m 的关键分组只有 34 笔。扩到 8~11 币或 5 万根再看是否稳定
|
||||
- [ ] **①c 审计执行层是否有去重/冷却**。「额外信号」是实时算得出、事后消失的,
|
||||
但实盘是否真的每个都下单,取决于执行层,这一层还没看过
|
||||
- [x] ⛔ **「中枢构成笔 is_sure」已证是空操作**(step70,§3.3994a)。
|
||||
`cal_bi_zs_list_pure` 本就强制全部构成笔 is_sure,789 个信号全是已确认,
|
||||
加这道门 PF 0.73 → 0.73。**不要再提这条**
|
||||
- [ ] **② 先测数量级放大:那 592 个额外信号落在几个不同中枢上**(§3.3994a)。
|
||||
中枢层面只改 6.5%,信号层面却 75%,怀疑 `max_per_zone=1` + 起点棘轮
|
||||
让同一中枢反复产出「第一个」入场点。脚本已写好 `step69_mechanism.py`,
|
||||
**三次后台运行都被中断,需在前台重跑**。这是修法的前置——
|
||||
若确是重复触发,修法是「每中枢只认一次」,代价远小于动 available_ts
|
||||
- [ ] **③ 若非重复触发,再试「等 N 根看信号是否仍在」**(用滞后换稳定性)
|
||||
- [ ] **③ 通知服务器侧**:当前实盘在做一批回测里不存在的单。在 ① 出结果前
|
||||
不建议改参数,但应知晓风险
|
||||
- [x] **窗口左边界效应判为否**(§3.3994)。2001 根滚动窗口与 2万→4万根增长窗口
|
||||
跑出完全相同的 789/197/592,中枢是局部结构,截断无影响
|
||||
- [ ] **一类线:问题已完全定性,等一个决策**(§3.3992 / §3.3993)。
|
||||
全局只有一个变量——**「是不是真线段端点」**:是则 PF 2.29(5m)/4.11(15m),
|
||||
否则恒为 0.13,其余特征都只是它的噪声代理。**盈亏平衡精度 5m 72.6%、
|
||||
15m 67.1%**,当前 28.7%,最好的实时特征 `ext_run` 到 44%。
|
||||
要继续就是做一个「实时判真底」的分类器打到 ~70%,标签/特征/评估口径
|
||||
§3.3993 已给全,但先验很难,且 70% 只是毛平衡,还要再扣 15 根因果滞后。
|
||||
**这条不阻塞任何实盘工作**
|
||||
- [ ] **一类超时从 48 根放宽**(§3.3992,仍未跑,代价很低)。线段中位时长 113 根,
|
||||
48 根在行情走完 42% 时平仓,真底那批 MFE 中位仅 2.37 ATR 疑似被截断
|
||||
- [x] **二类关闭**(step64,§3.3991)。与一类不同,二类是**信号消失**而非信号指反:
|
||||
两个方向 t 都贴零,且入场离结构止损位 4.87 ATR(一类 2.90),
|
||||
§3.3992 的结构止损解法对它不适用
|
||||
- [x] **step39**:1m 时点重建 + 窗口扫描 → **2000 根窗口饱和,计算 0.20s,
|
||||
假阳性 0%,1m 回测口径可信**
|
||||
- [ ] **对齐 step23 与 step42 的 1m 数字**(3.91bp vs 11.06bp)。差距可能来自
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 已 superseded
|
||||
|
||||
v0 改走免费交易所,由 **data_provider** 拉,chan 只从中转取。
|
||||
|
||||
见 [PROMOTE_deriv_relay.md](./PROMOTE_deriv_relay.md)。
|
||||
|
||||
CoinGlass 付费档等看完交易所效果再开,不要和本阶段混做。
|
||||
@@ -0,0 +1,94 @@
|
||||
# 需求:资金面数据(data_provider → chan)
|
||||
|
||||
**提出方:** chan
|
||||
**执行方:** data_provider
|
||||
**阶段:** Paper。先看效果。不进 Live。
|
||||
|
||||
---
|
||||
|
||||
## 要什么
|
||||
|
||||
chan 要在缠论图上叠资金面,和现有 K 线对得上。
|
||||
|
||||
data_provider 对外提供资金面;chan **只从 data_provider 取**,不访问交易所,不访问 CoinGlass。
|
||||
|
||||
K 线路径不动(现有 `/api/candles` 与 K 线推送)。本次只加资金面。
|
||||
|
||||
---
|
||||
|
||||
## 数据
|
||||
|
||||
先三个币:**BTC、ETH、SOL**(USDT 永续,符号与现有 K 线相同,如 `BTC/USDT:USDT`)。
|
||||
|
||||
要两样:
|
||||
|
||||
1. **持仓量(OI)**
|
||||
- 要历史,能覆盖缠论常用周期:`15m`、`30m`、`4h`、`1d`(有 `1h`/`2h` 更好)。
|
||||
- 要当前最新值。
|
||||
- 历史长度至少约 30 天。
|
||||
|
||||
2. **资金费率(funding)**
|
||||
- 要当前值。
|
||||
- 要历史结算序列。
|
||||
- 对齐到各周期 K 线:结算点落到所在那根;非结算 bar 沿用上一次结算值,不要插值编造。
|
||||
|
||||
来源:交易所公开数据即可,本阶段不买 CoinGlass。OI 历史哪家所没有,用另一家所公开数据补,需标明来源。
|
||||
|
||||
**本阶段不要:** 清算、热力图、多空比、订单簿、CoinGlass。
|
||||
|
||||
---
|
||||
|
||||
## 给 chan 的接口
|
||||
|
||||
与 `/api/candles` 同一套约定:
|
||||
|
||||
- `symbol` 与蜡烛相同
|
||||
- 时间戳毫秒 UTC
|
||||
- 按周期 `tf` 取序列
|
||||
- 支持 `start` / `end` / `limit`(默认 `limit=500`)
|
||||
|
||||
示例:
|
||||
|
||||
```http
|
||||
GET /api/deriv?symbol=BTC/USDT:USDT&tf=15m&metrics=oi,funding
|
||||
```
|
||||
|
||||
每根:
|
||||
|
||||
| 字段 | 要求 |
|
||||
|---|---|
|
||||
| `timestamp` | 与同 `tf` 的 `/api/candles` **开盘时间**对齐;对不齐的不要 |
|
||||
| `oi` | 该 bar 持仓量;缺则 `null` |
|
||||
| `oi_src` | 该值来自哪家所 |
|
||||
| `funding` | 该 bar 资金费率;缺则 `null` |
|
||||
| `funding_src` | 该值来自哪家所 |
|
||||
|
||||
健康状态要能看出:资金面是否可用、各所是否通、上次成功时间。
|
||||
|
||||
---
|
||||
|
||||
## 约束
|
||||
|
||||
- 全程 HTTPS REST。本阶段不要求资金面 WebSocket。
|
||||
- chan、浏览器不得直连交易所。
|
||||
- 现有 K 线接口行为不变。
|
||||
- 一家所挂了:缺那家字段,另一家仍要能出;两边都没有且无可用数据时明确失败。
|
||||
- 上游限流或超时:不要拖垮 K 线。
|
||||
|
||||
---
|
||||
|
||||
## 不算本次
|
||||
|
||||
- CoinGlass / 付费数据
|
||||
- 清算、热力、多空
|
||||
- Live、下单
|
||||
- chan 叠图(等本接口可用再做)
|
||||
|
||||
---
|
||||
|
||||
## 怎样算齐
|
||||
|
||||
1. `GET /api/deriv?symbol=BTC/USDT:USDT&tf=15m` 能拿到 `oi`、`funding`,时间能对上同参数的 `/api/candles`。
|
||||
2. chan / 浏览器零次访问交易所。
|
||||
3. 只挂一家所时,接口仍可用,只缺对应字段。
|
||||
4. K 线不受影响。
|
||||
@@ -0,0 +1,225 @@
|
||||
"""快速一类买卖点(fast B1/S1)——把引擎的 B1/S1 改写成当根可判的形式。
|
||||
|
||||
**动机**:step55 实测引擎 `find_all_bsp` 的 B1/S1 在 5m/15m 上是重亏的
|
||||
(PF 0.20~0.24、胜率 15~21%、t −17~−22),而且原因是几何性的:引擎在
|
||||
`leave_bi.sure_time` 才发信号,中位滞后 8~9 根,此时价格已朝反弹方向跑了
|
||||
1.68 ATR。逆势信号上,这个滞后是加倍惩罚——新入场价往下 2 ATR 的止损落在
|
||||
比原始低点还低 0.3 ATR 处,几乎贴着极值。
|
||||
|
||||
B3 当年也是同样的病(PF 0.66),解法不是调参而是**重新定义成实时判据**
|
||||
(B4,见 `chanlun/analysis/fast_bsp.py`)。本模块对一类做同样的事。
|
||||
|
||||
引擎 B1 是三个条件的合取(`bsp.py: find_all_bsp` + `check_bi_div`):
|
||||
|
||||
中枢已成 -> 一笔向下离开中枢 -> 该笔 MACD 面积 < 进入笔面积(底背驰)
|
||||
|
||||
三条都有当根可判的代理,滞后来源只有一处——`leave_bi.is_sure`:
|
||||
|
||||
中枢已成 zs.is_sure -> zones.available_ts(与 B4 同口径,已解决)
|
||||
向下离开 leave_bi 端点在 zd 下 -> **收盘** < zd,当根可判
|
||||
背驰 整笔面积对比 -> 进入笔面积在中枢确认时已是历史,
|
||||
离开段面积从突破根起逐根累加,当根可判
|
||||
触发 等笔确认(滞后之源) -> 收盘反向越过前一根极值(沿用 B4 的转强判据)
|
||||
|
||||
**与三类的方向关系相反**:三类顺着突破方向做,一类逆着做。中枢向下突破后,
|
||||
B4 会做空,而 fast B1 是在同一段下跌里找力竭然后**做多**。
|
||||
|
||||
⚠️ 先验不利,落地前请先看数:§3.391 实测 `mom60` 最高的四分位是最差的
|
||||
(余量只剩 1.44bp),而一类按定义就住在一段已走完的行情末端。本模块是用来
|
||||
证伪这个先验的,不是用来假定它不成立的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from chanlun.analysis.fast_bsp import _resolve_avail_bi, timestamps_ms
|
||||
|
||||
|
||||
def zones_with_enter_area(zs_list, src: pd.DataFrame,
|
||||
avail_bi: int | None = None) -> pd.DataFrame:
|
||||
"""区间表 + 进入笔的 MACD 面积与方向。
|
||||
|
||||
这两列**不引入滞后**:进入笔是中枢第一笔的前一笔,中枢确认时它早已收尾。
|
||||
背驰判据里唯一需要等待的是离开段,而那一段可以逐根累加。
|
||||
|
||||
面积口径必须和 `ChanBI.cal_macd_hist` 一致:只累加顺方向那一侧的 hist
|
||||
并取绝对值(上升笔累加正值,下降笔累加负值的绝对值),故恒非负。
|
||||
"""
|
||||
ts_of = dict(zip(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
timestamps_ms(src)))
|
||||
i = _resolve_avail_bi(avail_bi)
|
||||
|
||||
rows = []
|
||||
for zs in zs_list:
|
||||
bis = getattr(zs, "bi_list", [])
|
||||
if not bis:
|
||||
continue
|
||||
key_bi = bis[i] if (i == -1 or len(bis) > i) else bis[-1]
|
||||
avail = (ts_of.get(str(getattr(key_bi, "sure_time", "") or ""))
|
||||
or ts_of.get(str(getattr(key_bi, "end_time", "") or "")))
|
||||
if avail is None:
|
||||
continue
|
||||
enter_bi = getattr(bis[0], "pre", None)
|
||||
if enter_bi is None:
|
||||
continue
|
||||
# dir 用 +1/−1 表示,避免把引擎枚举漏进研究侧
|
||||
e_dir = 1 if str(enter_bi.dir).endswith("UP") else -1
|
||||
rows.append({
|
||||
"zg": float(zs.zg), "zd": float(zs.zd),
|
||||
"start_ts": ts_of.get(str(bis[0].start_time), avail),
|
||||
"available_ts": int(avail),
|
||||
"enter_area": abs(float(enter_bi.macd_hist)),
|
||||
"enter_dir": e_dir,
|
||||
})
|
||||
out = pd.DataFrame(rows)
|
||||
if out.empty:
|
||||
return out
|
||||
return out.sort_values("available_ts").reset_index(drop=True)
|
||||
|
||||
|
||||
def find_fast_bsp1(
|
||||
df: pd.DataFrame,
|
||||
zones: pd.DataFrame,
|
||||
scan: int = 200,
|
||||
leave_win: int = 60,
|
||||
min_leave_bars: int = 3,
|
||||
max_div: float = 1.0,
|
||||
max_per_zone: int = 1,
|
||||
diag: dict | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""扫描每个中枢,找「离开中枢后力度背驰、随即转向」的入场点。
|
||||
|
||||
zones 需含 zg / zd / available_ts / enter_area / enter_dir,
|
||||
即 `zones_with_enter_area` 的输出。
|
||||
|
||||
`min_leave_bars` 要求离开段至少走这么多根才允许触发。没有它的话,转强判据
|
||||
会在突破后第一根小反弹就打进去,那时离开段的面积还没累起来,背驰条件几乎
|
||||
自动成立——会退化成「随便一个反弹就抄底」。
|
||||
|
||||
`max_div` 是背驰的松紧:离开段面积 / 进入段面积 < max_div 才算背驰。
|
||||
1.0 等于引擎的 `macdhist_div < 0`;调小则只认力度衰减更明显的。
|
||||
|
||||
返回列:
|
||||
entry_idx 实时可下单的K线
|
||||
direction +1 一买 / −1 一卖(**与突破方向相反**)
|
||||
bo_idx 离开中枢的突破根
|
||||
ext_idx 离开段的极值根
|
||||
lag entry_idx − ext_idx,即相对理想入场点的滞后
|
||||
div 离开段面积 / 进入段面积,越小背驰越强
|
||||
depth 离开段极值越过中枢边界的幅度(相对边界)
|
||||
"""
|
||||
need = {"zg", "zd", "available_ts", "enter_area", "enter_dir"}
|
||||
if zones.empty or not need <= set(zones.columns):
|
||||
return pd.DataFrame()
|
||||
|
||||
ts = df["timestamp"].to_numpy()
|
||||
close = df["close"].to_numpy(float)
|
||||
high = df["high"].to_numpy(float)
|
||||
low = df["low"].to_numpy(float)
|
||||
hist = df["macdhist"].to_numpy(float)
|
||||
atr = df["atr"].to_numpy(float)
|
||||
n = len(df)
|
||||
rows = []
|
||||
|
||||
def note(key: str) -> None:
|
||||
if diag is not None:
|
||||
diag[key] = diag.get(key, 0) + 1
|
||||
|
||||
for zone_i, (_, z) in enumerate(zones.iterrows()):
|
||||
note("中枢总数")
|
||||
zg, zd = float(z["zg"]), float(z["zd"])
|
||||
e_area, e_dir = float(z["enter_area"]), int(z["enter_dir"])
|
||||
if zg <= zd or not np.isfinite(e_area) or e_area <= 0:
|
||||
note("×无效中枢或进入段无面积")
|
||||
continue
|
||||
start = int(np.searchsorted(ts, z["available_ts"], side="left"))
|
||||
if start >= n - 2:
|
||||
note("×中枢太靠后")
|
||||
continue
|
||||
scan_end = min(start + scan * max_per_zone, n)
|
||||
cursor = start
|
||||
|
||||
for occ in range(1, max_per_zone + 1):
|
||||
if cursor >= n - 2:
|
||||
break
|
||||
was_inside = False
|
||||
bo_idx, d = None, 0
|
||||
for j in range(cursor, scan_end):
|
||||
c = close[j]
|
||||
if zd <= c <= zg:
|
||||
was_inside = True
|
||||
continue
|
||||
if not was_inside:
|
||||
continue
|
||||
bo_idx, d = j, (1 if c > zg else -1)
|
||||
break
|
||||
if bo_idx is None:
|
||||
if occ == 1:
|
||||
note("×窗口内未离开中枢")
|
||||
break
|
||||
|
||||
# 引擎要求 enter_bi.dir == leave_bi.dir 才比面积,否则背驰无从谈起
|
||||
if e_dir != d:
|
||||
note("×进入段与离开段不同向")
|
||||
cursor = bo_idx + 1
|
||||
continue
|
||||
|
||||
area = 0.0
|
||||
ext = low[bo_idx] if d == -1 else high[bo_idx]
|
||||
ext_idx = bo_idx
|
||||
entry_idx, div_at = None, np.nan
|
||||
for j in range(bo_idx, min(bo_idx + leave_win + 1, n)):
|
||||
h = hist[j]
|
||||
# 与 ChanBI.cal_macd_hist 同口径:只取顺方向一侧,取绝对值
|
||||
if d == -1 and h < 0:
|
||||
area -= h
|
||||
elif d == 1 and h > 0:
|
||||
area += h
|
||||
if (low[j] < ext) if d == -1 else (high[j] > ext):
|
||||
ext, ext_idx = (low[j] if d == -1 else high[j]), j
|
||||
if j < bo_idx + min_leave_bars:
|
||||
continue
|
||||
# 收盘回到中枢内 -> 这不是一次有效的离开,弃掉
|
||||
if zd <= close[j] <= zg:
|
||||
note("×离开段收盘回到中枢内")
|
||||
break
|
||||
if area >= e_area * max_div:
|
||||
continue # 力度未衰减,不是背驰
|
||||
go = close[j] > high[j - 1] if d == -1 else close[j] < low[j - 1]
|
||||
if go:
|
||||
entry_idx, div_at = j, area / e_area
|
||||
break
|
||||
if entry_idx is None:
|
||||
if occ == 1:
|
||||
note("×未在窗口内背驰转向")
|
||||
cursor = bo_idx + 1
|
||||
continue
|
||||
if occ == 1:
|
||||
note("√成交")
|
||||
|
||||
edge = zd if d == -1 else zg
|
||||
# 延伸度用 ATR 归一,才和 step62 的 ext_run 同口径 —— 那里实测它是
|
||||
# 唯一单调区分「趋势末端 vs 趋势中途」的特征(命中率 12%→44%)。
|
||||
# 用相对边界的百分比会混入价格水平,不同币之间不可比。
|
||||
a_ext = atr[ext_idx]
|
||||
rows.append({
|
||||
"entry_idx": entry_idx, "direction": -d,
|
||||
"bo_idx": bo_idx, "ext_idx": ext_idx,
|
||||
"lag": entry_idx - ext_idx,
|
||||
"div": div_at,
|
||||
"ext_atr": (abs(ext - edge) / a_ext
|
||||
if np.isfinite(a_ext) and a_ext > 0 else np.nan),
|
||||
"depth": abs(ext - edge) / edge,
|
||||
"zg": zg, "zd": zd,
|
||||
"width_pct": (zg - zd) / close[bo_idx],
|
||||
"occ": occ, "zone_i": zone_i,
|
||||
})
|
||||
cursor = entry_idx + 1
|
||||
|
||||
out = pd.DataFrame(rows)
|
||||
if out.empty:
|
||||
return out
|
||||
return (out.sort_values(["entry_idx", "occ", "zone_i"])
|
||||
.drop_duplicates("entry_idx", keep="first")
|
||||
.reset_index(drop=True))
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Step 55:一/二类买卖点的可行性探针——先量滞后,不急着跑收益。
|
||||
|
||||
用户提出把 B4 那套研究思路搬到一二类上。搬之前必须先过一道闸,理由写在
|
||||
`Chan_BSP_TYPE` 的枚举注释里:**B4 存在的全部意义就是"几何位置同 B3/S3,
|
||||
但不等笔确认"**。也就是说这个项目早就付过"等笔确认"的学费。
|
||||
|
||||
而一类买卖点是最滞后的一种构造:它要中枢 `is_sure`、要离开笔 `is_sure`、
|
||||
还要背驰判定(`check_bi_div` 读 `macd_hist`)。二类更靠后,要在一类之后再走
|
||||
两笔。所以真正的问题不是"一二类赚不赚钱",而是:
|
||||
|
||||
你能在什么时候知道它,那时候价格还在不在。
|
||||
|
||||
`ChanBSP` 给了两个时刻,差值就是答案:
|
||||
klc.end_time = leave_bi.end_klc 的收盘时刻,**极值所在**,理想入场点
|
||||
sure_time = 笔被确认的时刻,**你最早能动手的时刻**
|
||||
|
||||
本步只回答三件事,跑得快、结论硬:
|
||||
1. 一二类各有多少笔(对比 B4 的 5.3 笔/天 / 10 币)
|
||||
2. 滞后多少根
|
||||
3. 这段等待里价格跑掉多少个 ATR —— 这是"入场价漂移",直接从余量里扣
|
||||
|
||||
⚠️ 不在本步做收益回测。滞后若不可接受,收益怎么算都是假的:`walk_exits`
|
||||
用的是信号根的次根开盘价,而那个价在一二类上根本拿不到。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
|
||||
os.environ.setdefault(v, "1")
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
pd.set_option("display.width", 340)
|
||||
|
||||
OUT = HERE / "out" / "step55_bsp12.feather"
|
||||
# B4 的对照基线,来自 §3.31 / step48:10 币 1m 实盘口径
|
||||
B4_PER_DAY_10SYM = 5.3
|
||||
# 出场结构与实盘完全一致,见 live/exit_params.py
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
|
||||
|
||||
def collect(sym: str, rows: int, tf: str = "1m") -> pd.DataFrame | None:
|
||||
import warnings as _w
|
||||
_w.filterwarnings("ignore")
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
# 必须走 full:check_bi_div 读 macd_hist,那只在 full 下算。
|
||||
# 而 TF_DF 自己**不填** bsp_list——web 的 analyze.py 是显式调
|
||||
# find_all_bsp 的,这里照抄那条链,口径才对得上
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bi_zs = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bi_zs:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bi_zs)
|
||||
if not bsp:
|
||||
return None
|
||||
|
||||
# 索引映射有两个坑,都会静默给出错的下标:
|
||||
# ① cdf.date 带时区(Asia/Shanghai),而 KLC 上的时间是**字符串**且
|
||||
# 不带时区。它们本就是同一个时钟的墙上时间,所以去 tz 而不是硬贴。
|
||||
# ② cdf.date 的单位是 datetime64[ms],`astype("int64")` 给的是毫秒,
|
||||
# 而 `Timestamp.value` 是纳秒,差 1e6 倍——手工转整数会让
|
||||
# searchsorted 全部落到末尾,且不报错。交给 DatetimeIndex 自己比。
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
if t.tz is not None:
|
||||
t = t.tz_localize(None)
|
||||
return int(didx.searchsorted(t))
|
||||
|
||||
op = cdf["open"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1),
|
||||
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
|
||||
rec = []
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
# 极值根:笔末 KLC 的收盘时刻。KLC 是合并后的,取它覆盖的最后一根
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
a = atr[i_ext]
|
||||
if not np.isfinite(a) or a <= 0:
|
||||
continue
|
||||
# 理想价:极值根收盘。可执行价:确认根的**次根开盘**——与
|
||||
# walk_exits / 实盘的口径一致(信号根收盘后才下单)
|
||||
px_ideal = cl[i_ext]
|
||||
j = min(i_sure + 1, n - 1)
|
||||
px_real = op[j]
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": name, "dir": d,
|
||||
"date_ext": dser.iloc[i_ext], "date_sure": dser.iloc[i_sure],
|
||||
"i_ext": i_ext, "i_sure": i_sure,
|
||||
"lag_bars": i_sure - i_ext,
|
||||
"atr_bp": a / px_ideal * 1e4,
|
||||
# 结构位:笔末 KLC 的实际极值,不是收盘。止损要挂在它外面
|
||||
# 才谈得上「守住结构」,用收盘价会把插针那一段漏掉
|
||||
"ext_px": float(b.klc.low if d == 1 else b.klc.high),
|
||||
"entry_px": px_real,
|
||||
# 等待期间价格顺着信号方向跑掉了多少(正 = 你追高/追空,吃亏)
|
||||
"drift_atr": (px_real - px_ideal) * d / a,
|
||||
"drift_bp": (px_real - px_ideal) * d / px_ideal * 1e4,
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
# 入场口径与实盘一致:确认根收盘后下单,walk_exits 用 entry_idx+1 的
|
||||
# 开盘价,ATR 取 entry_idx 那根。所以 entry_idx = i_sure,**不是**
|
||||
# i_ext——用极值根等于假设你能买在笔的低点,那是未来函数
|
||||
r = r[(r.i_sure < len(cdf) - 2) & np.isfinite(atr[r.i_sure.values])
|
||||
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
sig = pd.DataFrame({"entry_idx": r.i_sure.values,
|
||||
"direction": r.dir.values})
|
||||
res = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
|
||||
runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
if len(res) != len(r):
|
||||
print(f" {sym} {tf} 长度不齐 {len(res)} vs {len(r)},跳过",
|
||||
flush=True)
|
||||
return None
|
||||
for k in ("g", "r", "c", "b"):
|
||||
r[k] = res[f"{cfg}_{k}"].to_numpy()
|
||||
# 反向对照:胜率远低于随机说明信号带信息但可能指反了。出场是路径依赖的,
|
||||
# 不能把 g 取负号了事,必须拿相反方向重跑一遍
|
||||
flip = pd.DataFrame({"entry_idx": r.i_sure.values,
|
||||
"direction": -r.dir.values})
|
||||
rf = walk_exits(cdf, flip, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
|
||||
runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(rf) == len(r):
|
||||
for k in ("g", "r", "c", "b"):
|
||||
r["f_" + k] = rf[f"{cfg}_{k}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
|
||||
# 原始 ATR,供结构止损换算用。atr_pct 除过价格,换不回来
|
||||
r["atr_at_entry"] = atr[r.i_sure.values]
|
||||
return r
|
||||
except Exception as e:
|
||||
print(f" {sym} 失败: {e!r}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame, span_days: float, n_sym: int) -> None:
|
||||
print("\n" + "=" * 100)
|
||||
print(f"########## 一、笔数:够不够做 ##########")
|
||||
t = d.groupby("type").agg(笔数=("lag_bars", "size"))
|
||||
t[f"{n_sym}币每天"] = (t["笔数"] / span_days).round(2)
|
||||
# 必须按币归一再比。B4 那个 5.3 是 10 币的合计,直接拿 5 币的数去除
|
||||
# 等于凭空把速率打对折
|
||||
t["每币每天"] = (t["笔数"] / span_days / n_sym).round(3)
|
||||
t["对B4倍数"] = (t["笔数"] / span_days / n_sym /
|
||||
(B4_PER_DAY_10SYM / 10)).round(2)
|
||||
print(t.to_string())
|
||||
print(f"\n对照基线:B4 在 1m 上 10 币 {B4_PER_DAY_10SYM} 笔/天 = "
|
||||
f"**{B4_PER_DAY_10SYM/10:.3f} 笔/币/天**。"
|
||||
f"本表 {n_sym} 币 / 跨 {span_days:.0f} 天。")
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 二、滞后:从极值到可动手,差多少根 ##########")
|
||||
rows = []
|
||||
for name, g in d.groupby("type"):
|
||||
q = g.lag_bars.quantile([0.25, 0.5, 0.75, 0.9]).round(1)
|
||||
rows.append({"类型": name, "笔数": len(g),
|
||||
"滞后中位": q[0.5], "P25": q[0.25], "P75": q[0.75],
|
||||
"P90": q[0.9], "均值": round(g.lag_bars.mean(), 1),
|
||||
"最大": int(g.lag_bars.max())})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n1m 上 1 根 = 1 分钟。B4 的滞后是 0 根——它在信号根收盘即可下单。")
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 三、等待的代价:价格跑掉了多少 ##########")
|
||||
print("drift 为正 = 等待期间价格顺着信号方向走了,你只能追;这一段直接从"
|
||||
"余量里扣")
|
||||
print("⚠️ 「追不上占比」必然是 100%,那是定义决定的不是实测发现:笔之所以"
|
||||
"在那里结束,\n 正是因为价格从那个极值反向走了——`bi.end_klc` 是极值"
|
||||
"点,之后必然朝信号方向偏离。\n **有信息量的是幅度,不是符号。**")
|
||||
rows = []
|
||||
for name, g in d.groupby("type"):
|
||||
q = g.drift_atr.quantile([0.25, 0.5, 0.75]).round(2)
|
||||
rows.append({
|
||||
"类型": name, "笔数": len(g),
|
||||
"漂移中位(ATR)": q[0.5], "P25": q[0.25], "P75": q[0.75],
|
||||
"漂移均值(ATR)": round(g.drift_atr.mean(), 2),
|
||||
"漂移均值(bp)": round(g.drift_bp.mean(), 1),
|
||||
"中位ATR(bp)": round(g.atr_bp.median(), 1),
|
||||
"追不上占比": f"{(g.drift_atr > 0).mean()*100:.0f}%"})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 四、和止损宽度比:漂移吃掉多少风险预算 ##########")
|
||||
print("实盘止损是 2 ATR。若漂移中位已经是 1 ATR,等于你的止损只剩一半,"
|
||||
"而目标位还在原处——盈亏比被腰斩。")
|
||||
rows = []
|
||||
for name, g in d.groupby("type"):
|
||||
rows.append({
|
||||
"类型": name,
|
||||
"漂移/止损(2ATR)": f"{g.drift_atr.median()/2*100:.0f}%",
|
||||
"漂移超过 1ATR 占比": f"{(g.drift_atr > 1).mean()*100:.0f}%",
|
||||
"漂移超过 2ATR(已穿止损)": f"{(g.drift_atr > 2).mean()*100:.0f}%"})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
def profit(d: pd.DataFrame) -> None:
|
||||
"""各类买卖点按实盘出场结构的实际表现。
|
||||
|
||||
B3 是校验锚:HANDOFF §4 已记引擎 `find_all_bsp` 的 B3/S3 是系统性亏损
|
||||
(PF 0.66、胜率 27.4%、t −18.76)。若这里 B3 跑出个漂亮数字,说明口径接错了,
|
||||
先别信 B1/B2 的结果。
|
||||
"""
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
if "g" not in d.columns:
|
||||
print("\n(本次数据无回测列,跳过收益段;删掉 out/step55_bsp12.feather "
|
||||
"重跑可得)")
|
||||
return
|
||||
x = d.dropna(subset=["g"]).copy()
|
||||
x["net"] = x.g.values - fee_of(x.r.values, x.c.values)
|
||||
x["gR"] = x.g.values / (SL * x.atr_pct.values)
|
||||
x["R"] = x.net.values / (SL * x.atr_pct.values)
|
||||
x["tn"] = taker_notional(x.r.values, x.c.values)
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 五、按实盘出场结构(2/3/8/2/48)的实际表现 ##########")
|
||||
rows = []
|
||||
for name, g in x.groupby("type"):
|
||||
if len(g) < 40:
|
||||
continue
|
||||
w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum()
|
||||
rows.append({
|
||||
"类型": name, "笔数": len(g),
|
||||
"胜率": f"{(g.net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(g.gR.mean(), 3), "净均R": round(g.R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"R夏普": round(g.R.mean() / g.R.std(ddof=1), 3),
|
||||
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2),
|
||||
"中位持仓": int(g.b.median()),
|
||||
"t值": round(g.gR.mean() / (g.gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n⚠️ B3 是口径校验锚:HANDOFF §4 记的是 PF 0.66 / 胜率 27.4%。"
|
||||
"这里若明显更好,先怀疑接错了再信 B1/B2。")
|
||||
|
||||
if "f_g" not in x.columns:
|
||||
return
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 六、反向对照:信号是「指反了」还是「没有边」 ##########")
|
||||
print("胜率 18.6% 远低于 SL2/TP3 的随机基准约 40%,说明样本带信息。若只是"
|
||||
"方向标反了,\n反手做应显著为正;若反手也不赚,那就是入场时点本身"
|
||||
"已经过期,两边都吃滑点。")
|
||||
y = x.copy()
|
||||
y["fnet"] = y.f_g.values - fee_of(y.f_r.values, y.f_c.values)
|
||||
y["fgR"] = y.f_g.values / (SL * y.atr_pct.values)
|
||||
rows = []
|
||||
for name, g in y.groupby("type"):
|
||||
if len(g) < 40:
|
||||
continue
|
||||
w, o = g.fnet[g.fnet > 0].sum(), -g.fnet[g.fnet <= 0].sum()
|
||||
rows.append({
|
||||
"类型(反手)": name, "笔数": len(g),
|
||||
"胜率": f"{(g.fnet > 0).mean()*100:.1f}%",
|
||||
"毛R": round(g.fgR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"t值": round(g.fgR.mean() / (g.fgR.std(ddof=1) / np.sqrt(len(g))),
|
||||
2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m",
|
||||
help="要测的周期。1m 上滞后的绝对根数与长周期相同,"
|
||||
"但每根值的钱不同,所以周期是关键变量")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
ap.add_argument("--workers", type=int, default=3)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
d = pd.read_feather(OUT)
|
||||
else:
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
print(f"[一二类可行性探针] {len(syms)} 币 × {tfs} × {args.rows} 根"
|
||||
f"(full 模式,find_all_bsp 要 macd_hist)\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, args.rows, t): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
f"{0 if r is None else len(r)}", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
d.to_feather(OUT)
|
||||
|
||||
d["date_ext"] = pd.to_datetime(d["date_ext"])
|
||||
for tf, x in d.groupby("tf"):
|
||||
span = (x.date_ext.max() - x.date_ext.min()).total_seconds() / 86400
|
||||
print("\n" + "#" * 100)
|
||||
print(f"########## {tf} —— {len(x)} 个买卖点 · "
|
||||
f"{x.sym.nunique()} 币 · 跨 {span:.0f} 天 ##########")
|
||||
report(x, span, x.sym.nunique())
|
||||
profit(x)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""实时版一类买卖点(fast B1/S1)能否翻正。
|
||||
|
||||
step55 已判定引擎的 B1/S1 不可用(5m PF 0.24/0.20、胜率 18.6%/14.8%、
|
||||
t −17/−22),且病根是滞后 8~9 根带来的几何劣势,不是参数。B3 当年同病
|
||||
(PF 0.66),靠重新定义成实时判据翻到 1.59(B4)。本脚本对一类做同样的尝试。
|
||||
|
||||
对照组三条,缺一不可:
|
||||
引擎 B1/S1 step55 的数,说明「不改判据」是什么下场
|
||||
fast B1/S1 本次
|
||||
fast B3/S3 同一份数据、同一套出场跑 B4,确认管线本身能跑出正数
|
||||
—— 少了它,fast B1 若为负就分不清是判据不行还是管线接错了
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step56_fast_bsp1.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
|
||||
|
||||
def collect(sym: str, rows: int, tf: str) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
from lib.fast_bsp1 import find_fast_bsp1, zones_with_enter_area
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf)
|
||||
cdf = ensure_timestamp(chan.dataframe)
|
||||
zs_list = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zs_list:
|
||||
return None
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
parts = []
|
||||
|
||||
d1 = {}
|
||||
s1 = find_fast_bsp1(cdf, zones_with_enter_area(zs_list, cdf), diag=d1)
|
||||
if not s1.empty:
|
||||
s1["kind"] = "fastB1"
|
||||
parts.append(s1)
|
||||
# 同数据同出场跑一遍 B4,作为「管线能出正数」的存在性证明
|
||||
s3 = find_fast_bsp3(cdf, zones_from_zs_list(zs_list, cdf))
|
||||
if not s3.empty:
|
||||
s3["kind"] = "fastB3"
|
||||
parts.append(s3)
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
r = pd.concat(parts, ignore_index=True)
|
||||
r = r[(r.entry_idx < len(cdf) - 2)
|
||||
& np.isfinite(atr[r.entry_idx.values])
|
||||
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
res = walk_exits(cdf, r[["entry_idx", "direction"]], [SL], [RUNNER],
|
||||
[MAXB], scale_at=SCALE_AT, runners=(RUNNER,),
|
||||
runner_stops=(RSTOP,))
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for k in ("g", "r", "c", "b"):
|
||||
r[k] = res[f"{cfg}_{k}"].to_numpy()
|
||||
r["sym"], r["tf"] = sym, tf
|
||||
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
|
||||
r["date"] = cdf["date"].to_numpy()[r.entry_idx.values]
|
||||
r["diag"] = str(d1)
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def stats(g: pd.DataFrame) -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"R夏普": round(R.mean() / R.std(ddof=1), 3),
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"中位持仓": int(np.median(g.b.values)),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
for tf, x in d.groupby("tf"):
|
||||
span = (pd.to_datetime(x.date).max()
|
||||
- pd.to_datetime(x.date).min()).total_seconds() / 86400
|
||||
print("\n" + "#" * 92)
|
||||
print(f"########## {tf} · {x.sym.nunique()} 币 · 跨 {span:.0f} 天"
|
||||
f" ##########")
|
||||
rows = []
|
||||
for (kind, dirn), g in x.groupby(["kind", "direction"]):
|
||||
if len(g) < 40:
|
||||
continue
|
||||
nm = {"fastB1": ("一买", "一卖"), "fastB3": ("三买", "三卖")}[kind]
|
||||
rows.append({"信号": f"{kind} {nm[0 if dirn == 1 else 1]}",
|
||||
"每币每天": round(len(g) / span / x.sym.nunique(), 3),
|
||||
**stats(g)})
|
||||
if rows:
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n对照 · step55 引擎原生(同周期同出场):")
|
||||
print(" 5m B1 PF 0.24 胜率 18.6% t −17.3 | S1 PF 0.20 胜率 14.8% "
|
||||
"t −22.3")
|
||||
print(" 15m B1 PF 0.21 t −18.9 | S1 PF 0.23 t −15.9")
|
||||
|
||||
f1 = x[x.kind == "fastB1"]
|
||||
if len(f1) >= 120 and "ext_atr" in f1.columns:
|
||||
y = f1.dropna(subset=["ext_atr"])
|
||||
print("\n延伸度分档(ext_atr,step62 在滞后版上实测的唯一单调特征:"
|
||||
"\n命中线段顶点的比例 12%→44%,PF 0.12→0.46):")
|
||||
q = pd.qcut(y["ext_atr"], 4,
|
||||
labels=["Q1最短", "Q2", "Q3", "Q4最延伸"],
|
||||
duplicates="drop")
|
||||
print(pd.DataFrame([{"档": k, **stats(v)}
|
||||
for k, v in y.groupby(q, observed=True)
|
||||
if len(v) >= 30]).to_string(index=False))
|
||||
|
||||
print("\n⭐ 组合:低滞后(本模块)+ 延伸过滤(step62)—— "
|
||||
"唯一同时处理两个约束的路径")
|
||||
p50, p75 = y["ext_atr"].quantile(.50), y["ext_atr"].quantile(.75)
|
||||
dm = y["div"].median()
|
||||
rows = [{"过滤器": "无", **stats(y)}]
|
||||
for nm, g in [
|
||||
(f"ext≥P50({p50:.1f})", y[y["ext_atr"] >= p50]),
|
||||
(f"ext≥P75({p75:.1f})", y[y["ext_atr"] >= p75]),
|
||||
(f"ext≥P75 且 div≥中位", y[(y["ext_atr"] >= p75)
|
||||
& (y["div"] >= dm)]),
|
||||
(f"ext≥P50 且 div≥中位", y[(y["ext_atr"] >= p50)
|
||||
& (y["div"] >= dm)]),
|
||||
]:
|
||||
if len(g) >= 30:
|
||||
rows.append({"过滤器": nm, **stats(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("对照 · 同过滤器在滞后版(step62,5m):无 PF 0.22 → "
|
||||
"ext≥P75且div≥中位 PF 0.54")
|
||||
|
||||
if len(f1) >= 60:
|
||||
print("\n背驰强度分档(div = 离开段面积/进入段面积,越小背驰越强):")
|
||||
q = pd.qcut(f1["div"], 4, labels=["Q1最强", "Q2", "Q3", "Q4最弱"],
|
||||
duplicates="drop")
|
||||
print(pd.DataFrame([{"档": k, **stats(v)}
|
||||
for k, v in f1.groupby(q, observed=True)
|
||||
if len(v) >= 20]).to_string(index=False))
|
||||
print("\n滞后分档(lag = 入场根 − 离开段极值根):")
|
||||
b = pd.cut(f1["lag"], [-1, 1, 3, 6, 12, 1e9],
|
||||
labels=["≤1根", "2-3根", "4-6根", "7-12根", ">12根"])
|
||||
print(pd.DataFrame([{"档": k, **stats(v)}
|
||||
for k, v in f1.groupby(b, observed=True)
|
||||
if len(v) >= 20]).to_string(index=False))
|
||||
|
||||
dg = d[d.kind == "fastB1"].diag.dropna()
|
||||
if len(dg):
|
||||
print("\n" + "=" * 92)
|
||||
print("fast B1 漏斗(首个中枢样本):", dg.iloc[0])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
ap.add_argument("--workers", type=int, default=3)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
d = pd.read_feather(OUT)
|
||||
else:
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
print(f"[实时版一类] {len(syms)} 币 × {tfs} × {args.rows} 根\n",
|
||||
flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, args.rows, t): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
f"{0 if r is None else len(r)}", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""一类反手(fade B1/S1)是不是真的——逐币、分时段、与 B4 的重叠。
|
||||
|
||||
step55 的反向对照给出 5m/15m 上 PF 2.35~3.03、t +10~+13,且多空两边都正。
|
||||
数字太好,先按 §3.392 的教训做证伪:那次「周末效应」在 1m 上显著,换到
|
||||
7 年 5m/15m 样本就消失且符号反转。任何单一样本上的漂亮结果都要先过三关。
|
||||
|
||||
逐币 5 个币是不是都成立,还是被某一个币带起来的
|
||||
分时段 前后半段样本各自是否成立(时间外样本)
|
||||
与 B4 重叠 如果它只是换个名字的 B4,那就没有增量价值
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
from lib.exit_model import fee_of, taker_notional # noqa: E402
|
||||
|
||||
SRC = HERE / "out" / "step55_bsp12.feather"
|
||||
SL = 2.0
|
||||
|
||||
|
||||
def stats(g: pd.DataFrame) -> dict:
|
||||
"""反手口径的统计。f_* 是 step55 里用相反方向重跑出场得到的。"""
|
||||
net = g.f_g.values - fee_of(g.f_r.values, g.f_c.values)
|
||||
gR = g.f_g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.f_r.values, g.f_c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
d = pd.read_feather(SRC)
|
||||
if "f_g" not in d.columns:
|
||||
print("step55 数据里没有反手列,先重跑 step55")
|
||||
return
|
||||
d["date_ext"] = pd.to_datetime(d["date_ext"])
|
||||
one = d[d.type.isin(["B1", "S1"])].copy()
|
||||
|
||||
for tf, x in one.groupby("tf"):
|
||||
print("\n" + "#" * 92)
|
||||
print(f"########## {tf} · 一类反手 ##########")
|
||||
|
||||
print("\n【逐币】任何一个币独自撑起来的结果都不能要")
|
||||
print(pd.DataFrame([{"币": s, **stats(g)}
|
||||
for s, g in x.groupby("sym")]).to_string(index=False))
|
||||
|
||||
print("\n【分时段】按信号时间中位数切两半,后半段是时间外样本")
|
||||
cut = x.date_ext.median()
|
||||
for nm, g in (("前半", x[x.date_ext <= cut]), ("后半", x[x.date_ext > cut])):
|
||||
r = stats(g)
|
||||
print(f" {nm} {g.date_ext.min():%Y-%m-%d}~{g.date_ext.max():%Y-%m-%d}"
|
||||
f" {r}")
|
||||
|
||||
print("\n【分方向】B1反手=做空,S1反手=做多。只有一边成立就是单边行情")
|
||||
print(pd.DataFrame([{"原类型": t, **stats(g)}
|
||||
for t, g in x.groupby("type")]).to_string(index=False))
|
||||
|
||||
print("\n【逐年】")
|
||||
rows = []
|
||||
for y, g in x.groupby(x.date_ext.dt.year):
|
||||
if len(g) >= 40:
|
||||
rows.append({"年": y, **stats(g)})
|
||||
if rows:
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""结构止损:一类做多是被「止损太紧」打死的,还是趋势真的继续?
|
||||
|
||||
用户的观察:B1 做多的入场价 P 在结构低点 L 上方约 1.68 ATR,而固定止损是
|
||||
P − 2 ATR —— **只在 L 下方 0.32 ATR**。缠论里 B2 正是「回踩不破 L」的那次
|
||||
机会,属于预期之内的正常回抽。止损只留 0.32 ATR,一根插针就打掉,而那恰恰
|
||||
是该加仓的位置。
|
||||
|
||||
这与「趋势继续向下」是两个不同的失败模式,且**预测相反**:
|
||||
|
||||
止损太紧 把止损放到 L 下方足够远 -> 多头应被救活
|
||||
趋势继续 放宽止损只是亏得更多,且反手做空应持续为正
|
||||
|
||||
本脚本用逐笔的结构止损(挂在 L 外侧 margin 个 ATR)重跑,直接区分这两者。
|
||||
`walk_exits` 只支持全局固定止损,故这里自带模拟器;出场结构与实盘一致:
|
||||
减半于 SCALE_AT、剩余半仓目标 RUNNER、剩余半仓止损回到初始止损、MAXB 超时。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
from lib.data import fetch_ohlcv # noqa: E402
|
||||
from lib.exit_model import fee_of, taker_notional # noqa: E402
|
||||
|
||||
SRC = HERE / "out" / "step55_bsp12.feather"
|
||||
SCALE_AT, RUNNER, MAXB = 3.0, 8.0, 48
|
||||
|
||||
|
||||
def simulate(cdf: pd.DataFrame, trades: pd.DataFrame,
|
||||
stop_atr: np.ndarray, r_scale: bool = True) -> pd.DataFrame:
|
||||
"""逐笔止损宽度的出场模拟。stop_atr 是每笔各自的初始止损(ATR 倍数)。
|
||||
|
||||
与 `lib.exit_model.walk_exits` 的结构对齐:先减半仓于 SCALE_AT,剩余半仓
|
||||
看 RUNNER 或回到初始止损;未减仓则整仓在止损/超时了结。同根内止损优先于
|
||||
获利目标 —— 分辨不了根内先后时,按不利的一侧算,不给回测送分。
|
||||
|
||||
`r_scale=True` 时目标按 **R 倍数**而非 ATR 固定值放置:实盘的 2/3/8 等于
|
||||
在 1.5R 减半、4R 收尾,放宽止损却不放大目标会把盈亏比压到 1 以下,
|
||||
那样测出来的「放宽无效」是自证的。默认按 R 等比放大才是公平对照。
|
||||
"""
|
||||
k_scale = SCALE_AT / 2.0 if r_scale else None
|
||||
k_run = RUNNER / 2.0 if r_scale else None
|
||||
high = cdf["high"].to_numpy(float)
|
||||
low = cdf["low"].to_numpy(float)
|
||||
open_ = cdf["open"].to_numpy(float)
|
||||
close = cdf["close"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
out = []
|
||||
ent = trades.entry_idx.astype(int).to_numpy()
|
||||
dirs = trades.direction.astype(int).to_numpy()
|
||||
atrs = trades.atr_at_entry.to_numpy(float)
|
||||
for k in range(len(trades)):
|
||||
s, d, a, sl = ent[k], dirs[k], atrs[k], stop_atr[k]
|
||||
e = s + 1
|
||||
if e >= n - 1 or not np.isfinite(sl) or sl <= 0 or not np.isfinite(a):
|
||||
out.append((np.nan, "skip", 0, 0))
|
||||
continue
|
||||
tgt = sl * k_scale if r_scale else SCALE_AT
|
||||
run = sl * k_run if r_scale else RUNNER
|
||||
entry = open_[e]
|
||||
end = min(e + MAXB, n - 1)
|
||||
half_done = False
|
||||
g, why, bars = None, "timeout", end - e
|
||||
for j in range(e, end + 1):
|
||||
adv = (high[j] - entry) / a if d == 1 else (entry - low[j]) / a
|
||||
ret = (entry - low[j]) / a if d == 1 else (high[j] - entry) / a
|
||||
if ret >= sl: # 同根内止损优先
|
||||
g = ((-sl * a) / entry) * (0.5 if half_done else 1.0)
|
||||
if half_done:
|
||||
g += (tgt * a / entry) * 0.5
|
||||
why, bars = "stop", j - e
|
||||
break
|
||||
if not half_done and adv >= tgt:
|
||||
half_done = True
|
||||
if half_done and adv >= run:
|
||||
g = (tgt * 0.5 + run * 0.5) * a / entry
|
||||
why, bars = "target", j - e
|
||||
break
|
||||
if g is None:
|
||||
px = close[end]
|
||||
r = (px - entry) * d / entry
|
||||
g = (tgt * a / entry) * 0.5 + r * 0.5 if half_done else r
|
||||
why, bars = ("timeout_half" if half_done else "timeout"), end - e
|
||||
out.append((g, why, int(half_done), bars))
|
||||
return pd.DataFrame(out, columns=["g", "r", "c", "b"])
|
||||
|
||||
|
||||
def stats(g: pd.Series, r: pd.Series, c: pd.Series, atr_pct: np.ndarray,
|
||||
sl_used: np.ndarray) -> dict:
|
||||
net = g.values - fee_of(r.values, c.values)
|
||||
# R 用每笔自己的止损宽度归一,否则宽止损会被系统性低估风险
|
||||
denom = sl_used * atr_pct
|
||||
R = net / denom
|
||||
tn = taker_notional(r.values, c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"净均R": round(np.nanmean(R), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(np.nanmean(R) / (np.nanstd(R, ddof=1)
|
||||
/ np.sqrt(len(R))), 2),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
# step65 发现线段中位幅度只有 11.4 ATR,而 r_scale 会把 3.9 ATR 的结构止损
|
||||
# 对应的 runner 目标推到 15.6 ATR —— 比整段行情还长,永远打不到。
|
||||
# 本脚本首轮全程用了 r_scale=True,等于「放宽止损」和「目标够不着」同时生效,
|
||||
# 两个效应互相抵消,那轮的结论无效。绝对目标才是与 11.4 ATR 相容的口径。
|
||||
ap.add_argument("--abs-targets", action="store_true",
|
||||
help="目标位用绝对 ATR(3/8)而非按止损等比放大")
|
||||
args = ap.parse_args()
|
||||
|
||||
d = pd.read_feather(SRC)
|
||||
if "ext_px" not in d.columns:
|
||||
print("step55 数据缺 ext_px/entry_px,先重跑 step55")
|
||||
return
|
||||
d = d[(d.tf == args.tf) & d.type.isin(["B1", "S1"])].copy()
|
||||
|
||||
print(f"[结构止损] {args.tf} · 一类 {len(d)} 笔\n")
|
||||
print("=" * 92)
|
||||
print("########## 一、几何:固定 2ATR 止损离结构位有多远 ##########")
|
||||
# 入场价到结构极值的距离,用入场根的 ATR 归一(与 walk_exits 同口径)
|
||||
d["to_ext"] = (d.entry_px - d.ext_px) * d.dir / d.atr_at_entry
|
||||
d["margin_2atr"] = 2.0 - d.to_ext
|
||||
print(f"入场价到结构极值 (ATR) 中位 {d.to_ext.median():.2f} "
|
||||
f"P25 {d.to_ext.quantile(.25):.2f} P75 {d.to_ext.quantile(.75):.2f}")
|
||||
print(f"2ATR 止损在结构位外侧留的余地 (ATR) 中位 "
|
||||
f"{d.margin_2atr.median():.2f}")
|
||||
inside = (d.margin_2atr <= 0).mean()
|
||||
print(f"**止损落在结构位以内(还没到极值就被打掉)的占比:{inside*100:.1f}%**")
|
||||
print(" —— 这部分交易,价格连回踩到前低都不用,就已经出局了。")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 二、把止损挂到结构位外侧,多头能不能救活 ##########")
|
||||
print("margin = 止损挂在结构极值外侧几个 ATR。对照组是现行的固定 2 ATR。")
|
||||
from chanlun import TF_DF
|
||||
rows = []
|
||||
cache = {}
|
||||
for sym in sorted(d.sym.unique()):
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
|
||||
cache[(sym, args.tf)] = TF_DF(df, 1, args.tf).dataframe
|
||||
|
||||
print(f"目标位口径:{'绝对 3/8 ATR(装得进 11.4 ATR 的线段)' if args.abs_targets else '随止损等比放大(恒定 1.5R/4R)'}")
|
||||
for flip in (False, True):
|
||||
for label, margin in [("固定2ATR", None), ("结构+0.25", 0.25),
|
||||
("结构+0.5", 0.5), ("结构+1.0", 1.0),
|
||||
("结构+1.5", 1.5)]:
|
||||
gs, rs, cs, ap_, sl_ = [], [], [], [], []
|
||||
for sym, x in d.groupby("sym"):
|
||||
cdf = cache[(sym, args.tf)]
|
||||
x = x.reset_index(drop=True)
|
||||
t = pd.DataFrame({
|
||||
"entry_idx": x.i_sure.values,
|
||||
"direction": (-x.dir.values if flip else x.dir.values),
|
||||
"atr_at_entry": x.atr_at_entry.values})
|
||||
# 反手时结构位在**盈利**方向,不能拿它当止损;止损改挂在
|
||||
# 入场价另一侧同样宽度处,否则两组比的不是同一个东西
|
||||
sl = (np.full(len(x), 2.0) if margin is None
|
||||
else (x.to_ext.values + margin))
|
||||
res = simulate(cdf, t, sl, r_scale=not args.abs_targets)
|
||||
ok = res.g.notna().values
|
||||
gs.append(res.g[ok])
|
||||
rs.append(res.r[ok])
|
||||
cs.append(res.c[ok])
|
||||
ap_.append((x.atr_at_entry.values / x.entry_px.values)[ok])
|
||||
sl_.append(sl[ok])
|
||||
rows.append({"方向": "反手" if flip else "原方向", "止损": label,
|
||||
"中位宽度ATR": round(float(np.median(
|
||||
np.concatenate(sl_))), 2),
|
||||
**stats(pd.concat(gs, ignore_index=True),
|
||||
pd.concat(rs, ignore_index=True),
|
||||
pd.concat(cs, ignore_index=True),
|
||||
np.concatenate(ap_), np.concatenate(sl_))})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:若「止损太紧」是主因,放宽到结构位外侧应让原方向 PF 越过 1。"
|
||||
"\n若原方向放宽后仍在 1 以下、而反手始终显著为正,"
|
||||
"说明趋势确实在继续,\n那么该做的是反手,加宽止损只是少亏一点。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""因果性回放:一类反手的信号在当时真的发得出来吗?
|
||||
|
||||
§3.395 的 PF 2.3~3.0 建立在全量数据一次算完的 `bsp_list` 上。但增量模块的
|
||||
文件头自己写着「笔必须整表重扫:**最后一笔 is_sure 允许收回**」,§5.41 也记了
|
||||
中枢右边缘会重画。若信号是事后才浮现的,那个 PF 就是幻觉。
|
||||
|
||||
**做法**:用 `init_stream` 预热,随后逐根 `append_bar`,每根之后重算
|
||||
`cal_bi_zs_list_pure` + `find_all_bsp`,记录每个信号**第一次出现**在哪一根。
|
||||
入场用那一根(的次根开盘),而不是事后的 `sure_time` —— 实盘只能这样。
|
||||
|
||||
必须逐根,不能分段重建:在第 t 根用 `data[0:t+S]` 重算等于多给了 S 根的信息,
|
||||
测出来的因果性是假的。
|
||||
|
||||
**三个要看的量**
|
||||
召回 全量算出的信号,有多少在回放中真的出现过(没出现的是事后才浮现)
|
||||
幻影 回放中出现、但全量里没有的(当时发了、后来被重画掉)
|
||||
代价 回放首现根 vs 全量 sure_time 的滞后;以及按首现根入场的实际 PF
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step59_replay.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
WANT = {"B1": 1, "S1": -1}
|
||||
|
||||
|
||||
def sig_key(b) -> tuple | None:
|
||||
"""信号身份用「类型 + 极值 KLC 的结束时刻」。
|
||||
|
||||
不能用 sure_time 当身份:它正是会被重画的字段,用它做键会把同一个信号
|
||||
在不同根上算成两个。极值点稳定得多。
|
||||
"""
|
||||
t = getattr(b.type, "name", str(b.type))
|
||||
if t not in WANT:
|
||||
return None
|
||||
return (t, str(b.klc.end_time))
|
||||
|
||||
|
||||
def replay(sym: str, tf: str, rows: int, warm: int, steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < warm + steps + 100:
|
||||
print(f" {sym} {tf} 数据不足 {0 if df is None else len(df)}", flush=True)
|
||||
return None
|
||||
df = df.iloc[-(warm + steps):].reset_index(drop=True)
|
||||
|
||||
# ---- 全量口径:一次算完,作为对照 ----
|
||||
full = TF_DF(df, 1, tf, lean=False)
|
||||
fz = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
full_sig = {}
|
||||
for b in (full.find_all_bsp(full.bi_list, fz) or []):
|
||||
k = sig_key(b)
|
||||
if k and b.sure_time is not None:
|
||||
full_sig[k] = str(b.sure_time)
|
||||
|
||||
# ---- 回放口径:逐根追加,记录首现根 ----
|
||||
chan = TF_DF(df.iloc[:warm].copy(), 1, tf, lean=False)
|
||||
chan.init_stream(df.iloc[:warm].copy(), 1, tf)
|
||||
first_seen: dict[tuple, int] = {}
|
||||
for i in range(warm, len(df)):
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zs = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
bsp = chan.find_all_bsp(chan.bi_list, zs) if zs else []
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for b in (bsp or []):
|
||||
k = sig_key(b)
|
||||
if k and k not in first_seen:
|
||||
first_seen[k] = i
|
||||
|
||||
dser = pd.to_datetime(full.dataframe["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
rec = []
|
||||
for k in set(full_sig) | set(first_seen):
|
||||
t, ext_t = k
|
||||
i_seen = first_seen.get(k)
|
||||
# 只统计回放窗口内的:预热段的信号本来就不在考察范围
|
||||
i_ext = to_i(ext_t)
|
||||
if i_ext < warm - 200:
|
||||
continue
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": t, "dir": WANT[t],
|
||||
"in_full": k in full_sig, "in_replay": i_seen is not None,
|
||||
"i_ext": i_ext,
|
||||
"i_seen": -1 if i_seen is None else i_seen,
|
||||
"i_sure_full": to_i(full_sig[k]) if k in full_sig else -1,
|
||||
})
|
||||
r = pd.DataFrame(rec)
|
||||
if r.empty:
|
||||
return None
|
||||
|
||||
# 按回放首现根入场,跑与实盘一致的出场
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
cdf = full.dataframe
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
live = r[r.in_replay & (r.i_seen < len(cdf) - 2)].copy()
|
||||
live = live[np.isfinite(atr[live.i_seen.values])
|
||||
& (atr[live.i_seen.values] > 0)]
|
||||
if not live.empty:
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
# 反手:§3.395 判定该反着做
|
||||
t_ = pd.DataFrame({"entry_idx": live.i_seen.values,
|
||||
"direction": -live.dir.values})
|
||||
res = walk_exits(cdf, t_, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
|
||||
runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(live):
|
||||
for c in ("g", "r", "c"):
|
||||
live[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
live["atr_pct"] = atr[live.i_seen.values] / cl[live.i_seen.values]
|
||||
r = r.merge(live[["i_ext", "type", "g", "r", "c", "atr_pct"]],
|
||||
on=["i_ext", "type"], how="left")
|
||||
return r
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 一、召回与幻影 ##########")
|
||||
rows = []
|
||||
for (tf, t), x in d.groupby(["tf", "type"]):
|
||||
full = x[x.in_full]
|
||||
rep = x[x.in_replay]
|
||||
both = x[x.in_full & x.in_replay]
|
||||
rows.append({
|
||||
"tf": tf, "类型": t,
|
||||
"全量信号": len(full), "回放信号": len(rep),
|
||||
"召回": f"{len(both)/max(len(full),1)*100:.1f}%",
|
||||
"事后才浮现": len(full) - len(both),
|
||||
"幻影(被重画掉)": len(rep) - len(both),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n召回 = 全量算出的信号里,回放中真的出现过的比例。"
|
||||
"\n幻影 = 回放中发过、全量里却没有的 —— 实盘会照做,回测却看不见它。")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 二、时点代价:回放首现 vs 全量 sure_time ##########")
|
||||
b = d[d.in_full & d.in_replay].copy()
|
||||
b["delay"] = b.i_seen - b.i_sure_full
|
||||
for tf, x in b.groupby("tf"):
|
||||
q = x.delay.quantile([.25, .5, .75, .9])
|
||||
print(f" {tf} 中位 {q[.5]:+.0f} 根 P25 {q[.25]:+.0f} "
|
||||
f"P75 {q[.75]:+.0f} P90 {q[.9]:+.0f} "
|
||||
f"| 早于或等于全量的占比 {(x.delay <= 0).mean()*100:.0f}%")
|
||||
print(" 正值 = 回放比全量晚知道,实盘要在更差的价位入场。")
|
||||
|
||||
if "g" not in d.columns:
|
||||
return
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 三、真正能落地的收益:按回放首现根入场(反手)##########")
|
||||
x = d.dropna(subset=["g"]).copy()
|
||||
rows = []
|
||||
for (tf, t), g in x.groupby(["tf", "type"]):
|
||||
if len(g) < 30:
|
||||
continue
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
rows.append({
|
||||
"tf": tf, "类型(反手)": t, "笔数": len(g),
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
})
|
||||
if rows:
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n对照 · §3.395 全量口径 5m:B1反手 PF 2.35 / S1反手 2.75")
|
||||
print("若这里明显掉下来,说明那个 PF 吃了右边缘重画的红利,不可落地。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL")
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=60_000)
|
||||
ap.add_argument("--warm", type=int, default=20_000)
|
||||
ap.add_argument("--steps", type=int, default=10_000)
|
||||
ap.add_argument("--workers", type=int, default=3)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [x.strip() for x in args.symbols.split(",")]
|
||||
print(f"[因果回放] {len(syms)} 币 × {args.steps} 根逐根重放"
|
||||
f"(每根都要重算笔中枢与 bsp,慢是必然的)\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.tf, args.rows, args.warm,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
f"{0 if r is None else len(r)} 个信号", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,242 @@
|
||||
"""一类买卖点到底有没有找到真正的趋势反转点?用线段顶点当标准答案。
|
||||
|
||||
用户提出:线段的终点就是该级别的趋势反转点,虽然它是未来函数,但可以拿来当
|
||||
**标签**验证检测器,而不是拿来交易。这能把两件事分开:
|
||||
|
||||
检测器对不对 B1 是否真的落在趋势反转底上
|
||||
能不能交易 step59 已证否(实盘首现比 sure_time 晚中位 15 根,PF 塌到 0.92)
|
||||
|
||||
若检测器对而只是慢,那问题是延迟,还有救;若检测器本身就没找到反转点,
|
||||
这条线整个是死的。
|
||||
|
||||
⚠️ **对照组是这个测试的全部意义**。B1 按构造就长在笔的低点上,而笔低点本来
|
||||
就有一定概率撞上线段底。所以要问的不是「B1 命中率多少」,而是
|
||||
**「在所有同向笔端点里,B1 这个标签把命中率提高了多少倍」**。
|
||||
没有这个基准,任何绝对数字都可以随便解读。
|
||||
(同样的坑 fast_bsp 文档头踩过:回抽极值命中笔端点 19.3%,看着不低,
|
||||
但随机基准是 22%,其实是负贡献。)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step60_seg_truth.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
TOL = [0, 1, 2, 3, 5]
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
# ---- 标准答案:线段终点。下降线段终点=真底,上升线段终点=真顶 ----
|
||||
seg_bot, seg_top = [], []
|
||||
for sg in getattr(chan, "seg_list", []) or []:
|
||||
if sg.end_time is None:
|
||||
continue
|
||||
i = to_i(sg.end_time)
|
||||
if not (0 <= i < n):
|
||||
continue
|
||||
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN else seg_top).append(i)
|
||||
if not seg_bot or not seg_top:
|
||||
return None
|
||||
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
|
||||
|
||||
def near(i: int, d: int, tol: int) -> bool:
|
||||
a = truth[d]
|
||||
k = int(np.searchsorted(a, i))
|
||||
for j in (k - 1, k):
|
||||
if 0 <= j < len(a) and abs(int(a[j]) - i) <= tol:
|
||||
return True
|
||||
return False
|
||||
|
||||
rec = []
|
||||
# ---- 对照组:所有笔端点。B1 本就长在笔低点上,基准必须同源 ----
|
||||
for bi in chan.bi_list:
|
||||
if not getattr(bi, "is_sure", False) or bi.end_klc is None:
|
||||
continue
|
||||
d = 1 if str(bi.dir).endswith("DOWN") else -1 # 下降笔终点=低点
|
||||
i = to_i(bi.end_klc.end_time)
|
||||
if not (0 <= i < n):
|
||||
continue
|
||||
rec.append({"kind": "笔端点", "dir": d, "i_ext": i, "i_sure": -1})
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
rec.append({"kind": name, "dir": d, "i_ext": i_ext,
|
||||
"i_sure": i_sure})
|
||||
|
||||
r = pd.DataFrame(rec)
|
||||
for tol in TOL:
|
||||
r[f"hit{tol}"] = [near(i, d, tol)
|
||||
for i, d in zip(r.i_ext, r.dir)]
|
||||
|
||||
# 命中线段顶点的那批一类,按原方向(抄底)做能不能赚
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
sig = r[(r.kind.isin(["B1", "S1"])) & (r.i_sure >= 0)
|
||||
& (r.i_sure < n - 2)].copy()
|
||||
sig = sig[np.isfinite(atr[sig.i_sure.values])
|
||||
& (atr[sig.i_sure.values] > 0)]
|
||||
if not sig.empty:
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": sig.i_sure.values,
|
||||
"direction": sig.dir.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(sig):
|
||||
for c in ("g", "r", "c"):
|
||||
sig[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
sig["atr_pct"] = (atr[sig.i_sure.values]
|
||||
/ cl[sig.i_sure.values])
|
||||
r = r.merge(sig[["i_ext", "kind", "g", "r", "c", "atr_pct"]],
|
||||
on=["i_ext", "kind"], how="left")
|
||||
r["sym"], r["tf"] = sym, tf
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 92)
|
||||
print(f"########## {tf} · 线段顶点作为标准答案 ##########")
|
||||
print("\n【命中率】i_ext 落在同向线段终点 ±tol 根内的比例")
|
||||
rows = []
|
||||
for kind in ["笔端点", "B1", "S1", "B3", "S3"]:
|
||||
g = x[x.kind == kind]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
row = {"信号": kind, "样本": len(g)}
|
||||
for tol in TOL:
|
||||
row[f"±{tol}根"] = f"{g[f'hit{tol}'].mean()*100:.1f}%"
|
||||
rows.append(row)
|
||||
t = pd.DataFrame(rows)
|
||||
print(t.to_string(index=False))
|
||||
|
||||
base = x[x.kind == "笔端点"]
|
||||
print("\n【提升倍数】相对「所有同向笔端点」这个基准。"
|
||||
"≈1 就是没有信息量")
|
||||
rows = []
|
||||
for kind in ["B1", "S1", "B3", "S3"]:
|
||||
g = x[x.kind == kind]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
row = {"信号": kind}
|
||||
for tol in TOL:
|
||||
b = base[base.dir.isin(g.dir.unique())][f"hit{tol}"].mean()
|
||||
row[f"±{tol}根"] = (round(g[f"hit{tol}"].mean() / b, 2)
|
||||
if b > 0 else np.nan)
|
||||
rows.append(row)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
if "g" not in x.columns:
|
||||
continue
|
||||
print("\n【命中 vs 未命中】一类按原方向(抄底/摸顶)做的表现,"
|
||||
"tol=±2 根")
|
||||
y = x[x.kind.isin(["B1", "S1"])].dropna(subset=["g"]).copy()
|
||||
if len(y) < 60:
|
||||
continue
|
||||
rows = []
|
||||
for hit, g in y.groupby(y.hit2):
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
rows.append({
|
||||
"命中线段顶点": "是" if hit else "否", "笔数": len(g),
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1)
|
||||
/ np.sqrt(len(g))), 2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:若「命中」那组按原方向做显著为正,说明检测器是对的、"
|
||||
"只是掺了太多噪声,\n值得找实时可判的过滤器;若两组都为负,"
|
||||
"说明即使真站在线段底上,\n这个入场时点也已经太晚了。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
ap.add_argument("--workers", type=int, default=3)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
f"{0 if r is None else len(r)}", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,252 @@
|
||||
"""极值点的波动率:它是不是既拖慢了确认,又让目标够不着?
|
||||
|
||||
用户的观察:一二类买卖点长在极值点上,那个区域波动天然很大,这可能正是确认
|
||||
慢的原因。
|
||||
|
||||
这个假设若成立会同时解释两件事,而且机制不同:
|
||||
|
||||
确认慢 波动大 -> 分型/笔要更多根才稳定下来 -> sure_time 更晚
|
||||
赚不到 ATR 被造成极值的那根插针抬高 -> 止损 2×虚高ATR 其实宽松,
|
||||
但目标 3/8 ATR 变得够不着,因为入场后波动率会均值回复下来
|
||||
|
||||
第二条尤其要紧:它意味着交易不是「被打掉」,而是「永远走不到目标」,
|
||||
与 §3.397 判定的「趋势继续」是**不同的失败模式**,应对办法也不同
|
||||
(该换波动率口径,而不是换方向)。
|
||||
|
||||
三个量:
|
||||
atr_z 极值处 ATR / 该点之前 200 根的中位 ATR —— 是否真的偏高
|
||||
atr_fwd 入场后 48 根的平均 ATR / 入场时 ATR —— 是否均值回复
|
||||
与滞后相关 atr_z 高的信号,lag_bars 是否更长
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step61_vol.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
BASE_WIN = 200
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
# 基准 ATR 只用**该点之前**的窗口,含当根会把要检验的那根插针算进去
|
||||
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
|
||||
.median().shift(1).to_numpy())
|
||||
|
||||
rec = []
|
||||
# 对照组:所有笔端点,与 §3.397 同源
|
||||
for bi in chan.bi_list:
|
||||
if not getattr(bi, "is_sure", False) or bi.end_klc is None:
|
||||
continue
|
||||
i = to_i(bi.end_klc.end_time)
|
||||
if not (0 <= i < n) or not np.isfinite(base[i]) or base[i] <= 0:
|
||||
continue
|
||||
rec.append({"kind": "笔端点", "dir": 0, "i_ext": i, "i_sure": -1,
|
||||
"atr_z": atr[i] / base[i], "lag_bars": -1})
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1),
|
||||
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
if not np.isfinite(base[i_ext]) or base[i_ext] <= 0:
|
||||
continue
|
||||
rec.append({"kind": name, "dir": d, "i_ext": i_ext,
|
||||
"i_sure": i_sure, "atr_z": atr[i_ext] / base[i_ext],
|
||||
"lag_bars": i_sure - i_ext})
|
||||
|
||||
r = pd.DataFrame(rec)
|
||||
# 入场后的实现波动率:目标够不够得着,取决于入场**之后**的 ATR
|
||||
ok = r.i_sure >= 0
|
||||
fwd = np.full(len(r), np.nan)
|
||||
for k, i in zip(np.where(ok)[0], r.i_sure[ok].values):
|
||||
j = min(int(i) + 1 + MAXB, n)
|
||||
if j > int(i) + 1 and atr[int(i)] > 0:
|
||||
fwd[k] = np.nanmean(atr[int(i) + 1:j]) / atr[int(i)]
|
||||
r["atr_fwd"] = fwd
|
||||
|
||||
sig = r[ok & (r.i_sure < n - 2)].copy()
|
||||
sig = sig[np.isfinite(atr[sig.i_sure.values])
|
||||
& (atr[sig.i_sure.values] > 0)]
|
||||
if not sig.empty:
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": sig.i_sure.values,
|
||||
"direction": sig.dir.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(sig):
|
||||
for c in ("g", "r", "c", "b"):
|
||||
sig[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
sig["atr_pct"] = (atr[sig.i_sure.values]
|
||||
/ cl[sig.i_sure.values])
|
||||
r = r.merge(sig[["i_ext", "kind", "g", "r", "c", "b",
|
||||
"atr_pct"]], on=["i_ext", "kind"], how="left")
|
||||
r["sym"], r["tf"] = sym, tf
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 96)
|
||||
print(f"########## {tf} · 极值点的波动率 ##########")
|
||||
|
||||
print("\n【一】极值处 ATR 是不是真的偏高(atr_z = 当点ATR / 前200根中位ATR)")
|
||||
rows = []
|
||||
for kind in ["笔端点", "B1", "S1", "B2", "S2", "B3", "S3"]:
|
||||
g = x[x.kind == kind]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
rows.append({
|
||||
"信号": kind, "样本": len(g),
|
||||
"atr_z中位": round(g.atr_z.median(), 3),
|
||||
"P75": round(g.atr_z.quantile(.75), 3),
|
||||
"P90": round(g.atr_z.quantile(.90), 3),
|
||||
"高于基准占比": f"{(g.atr_z > 1).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n【二】波动大是不是确认更慢(按 atr_z 四分位看 lag_bars)")
|
||||
y = x[x.kind.isin(["B1", "S1"]) & (x.lag_bars >= 0)].copy()
|
||||
if len(y) >= 100:
|
||||
q = pd.qcut(y.atr_z, 4, labels=["Q1低", "Q2", "Q3", "Q4高"],
|
||||
duplicates="drop")
|
||||
print(pd.DataFrame([{
|
||||
"atr_z档": k, "笔数": len(g),
|
||||
"atr_z中位": round(g.atr_z.median(), 2),
|
||||
"滞后中位": round(g.lag_bars.median(), 1),
|
||||
"滞后均值": round(g.lag_bars.mean(), 1),
|
||||
} for k, g in y.groupby(q, observed=True)]).to_string(index=False))
|
||||
c = np.corrcoef(y.atr_z, y.lag_bars)[0, 1]
|
||||
print(f" 相关系数 corr(atr_z, lag_bars) = {c:+.3f}")
|
||||
print(" 正相关支持「波动大 -> 确认慢」;接近 0 则该假设不成立。")
|
||||
|
||||
print("\n【三】入场后波动率是否回落(atr_fwd = 后48根平均ATR / 入场ATR)")
|
||||
rows = []
|
||||
for kind in ["B1", "S1", "B3", "S3"]:
|
||||
g = x[(x.kind == kind)].dropna(subset=["atr_fwd"])
|
||||
if len(g) < 30:
|
||||
continue
|
||||
rows.append({
|
||||
"信号": kind, "样本": len(g),
|
||||
"atr_fwd中位": round(g.atr_fwd.median(), 3),
|
||||
"回落占比(<1)": f"{(g.atr_fwd < 1).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print(" <1 = 入场后波动率比入场那刻低,则按入场ATR定的 3/8 ATR 目标"
|
||||
"\n 在绝对价格上被高估,会系统性够不着。")
|
||||
|
||||
if "g" not in x.columns:
|
||||
continue
|
||||
print("\n【四】目标够不着的证据:出场原因分布(一类,按原方向)")
|
||||
z = x[x.kind.isin(["B1", "S1"])].dropna(subset=["g"])
|
||||
if len(z) >= 60:
|
||||
print((z.r.value_counts(normalize=True) * 100).round(1)
|
||||
.to_frame("占比%").to_string())
|
||||
print(f" 中位持仓 {z.b.median():.0f} 根 / 上限 {MAXB} 根")
|
||||
|
||||
print("\n【五】按 atr_z 分档看一类表现(原方向)")
|
||||
q = pd.qcut(z.atr_z, 4, labels=["Q1低", "Q2", "Q3", "Q4高"],
|
||||
duplicates="drop")
|
||||
rows = []
|
||||
for k, g in z.groupby(q, observed=True):
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
rows.append({
|
||||
"atr_z档": k, "笔数": len(g),
|
||||
"atr_z中位": round(g.atr_z.median(), 2),
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
ap.add_argument("--workers", type=int, default=3)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
f"{0 if r is None else len(r)}", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,271 @@
|
||||
"""怎么把「趋势末端的一类」从「趋势中途的一类」里挑出来。
|
||||
|
||||
§3.397 的关键数字:一类里命中线段顶点(真反转)的只有 30%,那 30% 即使带着
|
||||
8~9 根滞后也有 PF 0.70~0.83;**没命中的 70% 是 PF 0.08**。
|
||||
所以亏损几乎全部来自被误识别在趋势中途的那批 —— 用户的判断。
|
||||
|
||||
于是问题变成:有没有**实时可算**的特征能把两批分开。
|
||||
|
||||
**首要候选来自缠论本身**:一类买点要求的是**趋势背驰**,而趋势的定义是
|
||||
「至少两个同向连续的中枢」。引擎的 `find_all_bsp` 对**任意**中枢都发信号,
|
||||
完全没查这个前提 —— 单个盘整中枢上的「背驰」只是盘整背驰,本就不该当一类用。
|
||||
`fast_bsp.add_zone_ladder` 早就实现了这个判定(B4 上把 PF 2.72 提到 3.41),
|
||||
一类这边却没接。
|
||||
|
||||
测的特征全部只用信号时刻及之前的数据:
|
||||
|
||||
ladder 本中枢相对前一中枢是否同向推进(下降趋势要求 zg < 前一个 zd)
|
||||
zs_count 该中枢在本段里的序号,越大趋势越成熟
|
||||
div 离开段 MACD 面积 / 进入段面积,越小背驰越强
|
||||
ext_run 极值越过中枢边界多少个 ATR,越大越延伸
|
||||
atr_z 极值处波动率(§3.398)
|
||||
|
||||
评判分两层:**能否提高命中线段顶点的概率**(检测器精度),
|
||||
以及**能否提高实际收益**(可交易性)。前者好后者不好也没用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step62_trend_end.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
BASE_WIN, TOL = 200, 2
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
|
||||
.median().shift(1).to_numpy())
|
||||
|
||||
# 标准答案:线段终点(未来函数,只当标签用,不进入任何过滤器)
|
||||
seg_bot, seg_top = [], []
|
||||
for sg in getattr(chan, "seg_list", []) or []:
|
||||
if sg.end_time is None:
|
||||
continue
|
||||
i = to_i(sg.end_time)
|
||||
if 0 <= i < n:
|
||||
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN
|
||||
else seg_top).append(i)
|
||||
if not seg_bot or not seg_top:
|
||||
return None
|
||||
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
|
||||
|
||||
def near(i: int, d: int) -> bool:
|
||||
a = truth[d]
|
||||
k = int(np.searchsorted(a, i))
|
||||
return any(0 <= j < len(a) and abs(int(a[j]) - i) <= TOL
|
||||
for j in (k - 1, k))
|
||||
|
||||
# 中枢阶梯:按可用顺序排好,才谈得上「相对前一个」
|
||||
zs_seq = sorted(bz, key=lambda z: to_i(z.bi_list[0].start_time))
|
||||
pos = {id(z): k for k, z in enumerate(zs_seq)}
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1)}
|
||||
rec = []
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None or b.zs is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
a = atr[i_ext]
|
||||
if not np.isfinite(a) or a <= 0 or not np.isfinite(base[i_ext]):
|
||||
continue
|
||||
zs = b.zs
|
||||
k = pos.get(id(zs))
|
||||
# 趋势成熟度:本中枢往前数,连续同向推进的中枢有几个。
|
||||
# 单看「相对前一个是否同向」没有区分力 —— B1 要求
|
||||
# enter_bi.dir == leave_bi.dir == DOWN,即中枢向下进、向下出,
|
||||
# 这本身就定义了它嵌在下跌趋势里,连续纯中枢自然逐级下移,
|
||||
# 实测该条件在 2504 笔上恒为 True。**引擎已隐含强制了「趋势」前提。**
|
||||
# 有区分力的是「连了几级」,那才是趋势成熟度。
|
||||
ladder_n = 0
|
||||
if k is not None:
|
||||
j = k
|
||||
while j > 0:
|
||||
cur, prv = zs_seq[j], zs_seq[j - 1]
|
||||
ok = (float(cur.zg) < float(prv.zd) if d == 1
|
||||
else float(cur.zd) > float(prv.zg))
|
||||
if not ok:
|
||||
break
|
||||
ladder_n += 1
|
||||
j -= 1
|
||||
enter_bi = zs.bi_list[0].pre if zs.bi_list else None
|
||||
ea = abs(float(enter_bi.macd_hist)) if enter_bi is not None else np.nan
|
||||
la = abs(float(b.bi.macd_hist))
|
||||
edge = float(zs.zd) if d == 1 else float(zs.zg)
|
||||
ext = float(b.klc.low if d == 1 else b.klc.high)
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": name, "dir": d,
|
||||
"i_ext": i_ext, "i_sure": i_sure,
|
||||
"lag_bars": i_sure - i_ext,
|
||||
"hit": near(i_ext, d),
|
||||
"ladder_n": int(ladder_n),
|
||||
"div": la / ea if (ea and np.isfinite(ea) and ea > 0) else np.nan,
|
||||
"ext_run": abs(ext - edge) / a,
|
||||
"atr_z": a / base[i_ext],
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
r = r[(r.i_sure < n - 2) & np.isfinite(atr[r.i_sure.values])
|
||||
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.i_sure.values, "direction": r.dir.values}),
|
||||
[SL], [RUNNER], [MAXB], scale_at=SCALE_AT, runners=(RUNNER,),
|
||||
runner_stops=(RSTOP,))
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c", "b"):
|
||||
r[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "命中率": f"{g.hit.mean()*100:.1f}%",
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 96)
|
||||
print(f"########## {tf} · {len(x)} 笔一类 ##########")
|
||||
|
||||
print("\n【一】单特征对「命中线段顶点」的区分力(命中率基准 "
|
||||
f"{x.hit.mean()*100:.1f}%)")
|
||||
rows = []
|
||||
for nm, col, qs in [("背驰div", "div", 4), ("延伸ext_run", "ext_run", 4),
|
||||
("波动atr_z", "atr_z", 4), ("趋势级数ladder_n", "", 0)]:
|
||||
if not col:
|
||||
q = pd.cut(x["ladder_n"], [-1, 1, 2, 3, 999],
|
||||
labels=["级数≤1", "=2", "=3", "≥4"])
|
||||
for k, g in x.groupby(q, observed=True):
|
||||
if len(g) >= 30:
|
||||
rows.append({"分组": str(k), **perf(g)})
|
||||
continue
|
||||
y = x.dropna(subset=[col])
|
||||
if len(y) < 100:
|
||||
continue
|
||||
q = pd.qcut(y[col], qs,
|
||||
labels=[f"{nm}Q{i+1}" for i in range(qs)],
|
||||
duplicates="drop")
|
||||
for k, g in y.groupby(q, observed=True):
|
||||
if len(g) >= 30:
|
||||
rows.append({"分组": str(k), **perf(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n【二】叠加过滤:延伸是唯一单调的特征,看叠加还能不能推上去")
|
||||
rows = [{"过滤器": "无(现状)", **perf(x)}]
|
||||
e75 = x["ext_run"].quantile(.75)
|
||||
e50 = x["ext_run"].quantile(.50)
|
||||
for nm, g in [
|
||||
(f"ext_run≥P50({e50:.1f})", x[x["ext_run"] >= e50]),
|
||||
(f"ext_run≥P75({e75:.1f})", x[x["ext_run"] >= e75]),
|
||||
(f"ext_run≥P75 且 级数≥3",
|
||||
x[(x["ext_run"] >= e75) & (x["ladder_n"] >= 3)]),
|
||||
(f"ext_run≥P75 且 div≥中位",
|
||||
x[(x["ext_run"] >= e75) & (x["div"] >= x["div"].median())]),
|
||||
]:
|
||||
if len(g) >= 30:
|
||||
rows.append({"过滤器": nm, **perf(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:命中率若被显著抬高,说明特征确实在区分「趋势末端 vs 中途」。"
|
||||
"\n但 PF 才是能不能做的判据 —— 命中率上去而 PF 不过 1,"
|
||||
"说明滞后仍然吃掉了全部。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
ap.add_argument("--workers", type=int, default=3)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
f"{0 if r is None else len(r)}", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""B4 主线的因果回放:实盘信号的时点是不是干净的。
|
||||
|
||||
step59 在一类上抓到一个会骗人的坑:`sure_time` 是引擎**事后**标注的确认时刻,
|
||||
不等于可执行时刻,用它当 entry 的回测 PF 虚高一倍以上。B4 正在跑真钱,
|
||||
必须过同一关。
|
||||
|
||||
**先验比一类好,但方向要说清**(§5.41 的代码审计):
|
||||
|
||||
一类 `sure_time` 直接当**入场时刻** -> 早了就是虚高,回测被高估
|
||||
B4 `available_ts` 只是**扫描起点** -> 晚了只会漏信号,回测偏保守
|
||||
|
||||
§5.41 实测全量的 `available_ts` 系统性**更晚**(`bis[-1]` 取的是中枢结束而非
|
||||
形成,中位晚 62 分钟)。所以预期是「回测保守」而非「回测虚高」。但那是 300
|
||||
时点抽样 + 代码审计,不是逐根验证,而且留了个未知:
|
||||
**实盘会产出更多、更早的信号,那部分的质量不在回测统计里。**
|
||||
|
||||
本脚本逐根重放,同时量两边:
|
||||
|
||||
准时率 全量信号在其 entry_idx 当根就能算出来的比例
|
||||
迟到 首现晚于 entry_idx 的,实盘只能在更差的价位追
|
||||
额外信号 回放发得出、全量却没有的 —— §5.41 预言存在,但没人统计过它们赚不赚
|
||||
|
||||
⚠️ 性能:每根扫全部中枢跑不完。一个中枢只能在其 available_ts 之后 scan 根内
|
||||
出信号,所以每根只需把窗口内的中枢喂给 `find_fast_bsp3`。这是等价裁剪,
|
||||
不改变结果。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step63_b4_replay.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
SCAN = 200
|
||||
|
||||
|
||||
def replay(sym: str, tf: str, rows: int, warm: int,
|
||||
steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < warm + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(warm + steps):].reset_index(drop=True)
|
||||
|
||||
# ---- 全量口径:回测就是这么算的 ----
|
||||
full = TF_DF(df, 1, tf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zs_full = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
if not zs_full:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zs_full, cdf))
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
|
||||
# ---- 回放口径:逐根重算 zones 再扫 ----
|
||||
chan = TF_DF(df.iloc[:warm].copy(), 1, tf)
|
||||
chan.init_stream(df.iloc[:warm].copy(), 1, tf)
|
||||
first_seen: dict[tuple, int] = {}
|
||||
for i in range(warm, len(df)):
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z.empty:
|
||||
continue
|
||||
# 等价裁剪:available_ts 早于 scan 根之前的中枢,其扫描窗口
|
||||
# 已经过去,不可能在本根产出新信号
|
||||
lo = sub["timestamp"].to_numpy()[max(0, len(sub) - SCAN - 2)]
|
||||
z = z[z.available_ts >= lo]
|
||||
if z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z, scan=SCAN)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (int(r.entry_idx), int(r.direction))
|
||||
if k not in first_seen:
|
||||
first_seen[k] = i
|
||||
|
||||
rec = []
|
||||
for k in set(full_keys) | set(first_seen):
|
||||
e, d = k
|
||||
if e < warm: # 预热段不计入
|
||||
continue
|
||||
seen = first_seen.get(k)
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "entry_idx": e, "direction": d,
|
||||
"in_full": k in full_keys, "in_replay": seen is not None,
|
||||
"i_seen": -1 if seen is None else seen,
|
||||
"late": (np.nan if seen is None else seen - e),
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
|
||||
# 实盘真正会做的:首现根入场(首现==entry_idx 即准时)
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
live = r[r.in_replay & (r.i_seen < n - 2)].copy()
|
||||
live = live[np.isfinite(atr[live.i_seen.values])
|
||||
& (atr[live.i_seen.values] > 0)]
|
||||
if not live.empty:
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": live.i_seen.values,
|
||||
"direction": live.direction.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(live):
|
||||
for c in ("g", "r", "c"):
|
||||
live[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
live["atr_pct"] = (atr[live.i_seen.values]
|
||||
/ cl[live.i_seen.values])
|
||||
r = r.merge(live[["entry_idx", "direction", "g", "r", "c",
|
||||
"atr_pct"]],
|
||||
on=["entry_idx", "direction"], how="left")
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 一、准时率与额外信号 ##########")
|
||||
rows = []
|
||||
for tf, x in d.groupby("tf"):
|
||||
both = x[x.in_full & x.in_replay]
|
||||
rows.append({
|
||||
"tf": tf,
|
||||
"全量信号": int(x.in_full.sum()),
|
||||
"回放信号": int(x.in_replay.sum()),
|
||||
"召回": f"{len(both)/max(int(x.in_full.sum()),1)*100:.1f}%",
|
||||
"准时(首现==entry)": f"{(both.late == 0).mean()*100:.1f}%",
|
||||
"迟到中位": (f"{both.late[both.late > 0].median():.0f} 根"
|
||||
if (both.late > 0).any() else "—"),
|
||||
"额外信号": int((x.in_replay & ~x.in_full).sum()),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n额外信号 = 回放发得出、全量没有的。§5.41 预言它们存在"
|
||||
"(实盘 available_ts 更早 -> 信号更多更早),本表给出数量。")
|
||||
|
||||
if "g" not in d.columns:
|
||||
return
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 二、分组收益:回测口径 vs 实盘口径 ##########")
|
||||
for tf, x in d.groupby("tf"):
|
||||
y = x.dropna(subset=["g"])
|
||||
if len(y) < 30:
|
||||
continue
|
||||
print(f"\n--- {tf} ---")
|
||||
rows = []
|
||||
for nm, g in [
|
||||
("全部回放信号(=实盘会做的)", y[y.in_replay]),
|
||||
("其中 准时的", y[y.in_replay & (y.late == 0)]),
|
||||
("其中 迟到的", y[y.in_replay & (y.late > 0)]),
|
||||
("其中 额外的(全量没有)", y[y.in_replay & ~y.in_full]),
|
||||
("回测口径(全量∩回放)", y[y.in_full & y.in_replay]),
|
||||
]:
|
||||
if len(g) >= 30:
|
||||
rows.append({"分组": nm, **perf(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:若「全部回放信号」的 PF 不低于「回测口径」,说明 B4 的时点"
|
||||
"是干净的,\n且 §5.41 说的『回测偏保守』成立 —— 实盘拿到的反而更多。"
|
||||
"\n若额外信号那组显著更差,那就是回测没统计到的隐性成本。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--warm", type=int, default=20_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[B4 因果回放] {len(syms)} 币 × {args.steps} 根逐根重放\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.tf, args.rows, args.warm,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
f"{0 if r is None else len(r)} 个信号", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,257 @@
|
||||
"""二类买卖点:它只是一类的延迟版,还是一类里被确认过的那个子集?
|
||||
|
||||
用户提出的悖论:二类按定义是「一买之后回调、再继续趋势」,可一类已经证否了,
|
||||
二类凭什么会好?
|
||||
|
||||
代码上悖论成立——`find_all_bsp` 里 B2 确实被 B1 门控(`if first_bsp_bi_div`)。
|
||||
但 B2 比 B1 多要求两件事:价格**确实反弹了**(bounce_bi 向上),
|
||||
且回踩**守住了** B1 的低点(`second_bsp_bi.end_klc.low > leave_bi.end_klc.low`)。
|
||||
**这两条正是「那个底是真的」的事后确认。**
|
||||
|
||||
而 §3.397 的诊断恰恰是:一类只有 30% 落在真反转上,那 30% 的 PF 是 0.70~0.83,
|
||||
另外 70% 是 0.08。所以「按确认筛掉假底」正是一类缺的东西。
|
||||
|
||||
于是悖论变成一个可测的问题:
|
||||
|
||||
B2 命中线段顶点的比例,是否显著高于 B1 的 30%?
|
||||
|
||||
是 -> B2 是 B1 的**已验证子集**,悖论解除,值得继续查
|
||||
否 -> B2 只是 B1 的延迟版,用户的悖论成立,直接关掉
|
||||
|
||||
同时量三件与一类可比的东西(口径完全对齐,才能横向比):
|
||||
几何 入场价到结构止损位有多远(一类是 2.90 ATR,止损落在结构内侧 88.6%)
|
||||
波动 atr_z(一类 1.22 偏高,二类 0.94 偏低,画像相反)
|
||||
反手 一类反手在全量口径上曾看似很好,二类是否也有这个现象
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step64_b2.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
BASE_WIN, TOL = 200, 2
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
op = cdf["open"].to_numpy(float)
|
||||
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
|
||||
.median().shift(1).to_numpy())
|
||||
|
||||
seg_bot, seg_top = [], []
|
||||
for sg in getattr(chan, "seg_list", []) or []:
|
||||
if sg.end_time is None:
|
||||
continue
|
||||
i = to_i(sg.end_time)
|
||||
if 0 <= i < n:
|
||||
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN
|
||||
else seg_top).append(i)
|
||||
if not seg_bot or not seg_top:
|
||||
return None
|
||||
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
|
||||
|
||||
def near(i: int, d: int) -> bool:
|
||||
a = truth[d]
|
||||
k = int(np.searchsorted(a, i))
|
||||
return any(0 <= j < len(a) and abs(int(a[j]) - i) <= TOL
|
||||
for j in (k - 1, k))
|
||||
|
||||
# 一类按中枢建索引,好给二类找到它自己那个一类的低点 ——
|
||||
# 二类的天然止损位是**一类的极值**,不是它自己的极值
|
||||
one_ext: dict[int, float] = {}
|
||||
for b in bsp:
|
||||
if b.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1) and b.zs:
|
||||
d = 1 if b.type == Chan_BSP_TYPE.B1 else -1
|
||||
one_ext[id(b.zs)] = float(b.klc.low if d == 1 else b.klc.high)
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1)}
|
||||
rec = []
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
a = atr[i_ext]
|
||||
if not np.isfinite(a) or a <= 0 or not np.isfinite(base[i_ext]):
|
||||
continue
|
||||
own = float(b.klc.low if d == 1 else b.klc.high)
|
||||
# 二类的结构止损用一类的极值(回踩不破的就是那个点);
|
||||
# 一类用自己的极值。这样两者的「入场离结构位多远」才可比
|
||||
struct = own
|
||||
if name in ("B2", "S2") and b.zs is not None:
|
||||
struct = one_ext.get(id(b.zs), own)
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": name, "dir": d,
|
||||
"i_ext": i_ext, "i_sure": i_sure,
|
||||
"lag_bars": i_sure - i_ext,
|
||||
"hit": near(i_ext, d),
|
||||
"atr_z": a / base[i_ext],
|
||||
"own_ext": own, "struct_px": struct,
|
||||
"entry_px": op[min(i_sure + 1, n - 1)],
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
r = r[(r.i_sure < n - 2) & np.isfinite(atr[r.i_sure.values])
|
||||
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
r["atr_at_entry"] = atr[r.i_sure.values]
|
||||
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
|
||||
r["to_struct"] = ((r.entry_px - r.struct_px) * r.dir
|
||||
/ r.atr_at_entry)
|
||||
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
for sfx, sgn in (("", 1), ("f_", -1)):
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.i_sure.values,
|
||||
"direction": sgn * r.dir.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c", "b"):
|
||||
r[sfx + c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame, pre: str = "") -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
gg, rr, cc = g[pre + "g"].values, g[pre + "r"].values, g[pre + "c"].values
|
||||
net = gg - fee_of(rr, cc)
|
||||
gR = gg / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(rr, cc)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 96)
|
||||
print(f"########## {tf} ##########")
|
||||
|
||||
print("\n【一】悖论的判据:二类命中线段顶点的比例是否高于一类")
|
||||
rows = []
|
||||
for t in ["B1", "S1", "B2", "S2"]:
|
||||
g = x[x.type == t]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
rows.append({
|
||||
"类型": t, "样本": len(g),
|
||||
"命中线段顶点": f"{g.hit.mean()*100:.1f}%",
|
||||
"atr_z中位": round(g.atr_z.median(), 3),
|
||||
"滞后中位": int(g.lag_bars.median()),
|
||||
"入场到结构位(ATR)": round(g.to_struct.median(), 2),
|
||||
"止损2ATR落在结构内侧": f"{(g.to_struct > 2).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n 「入场到结构位」是入场价离天然止损位多少个 ATR。二类的结构位取"
|
||||
"\n 它那个一类的极值(回踩不破的就是那点)。>2 表示 2ATR 的止损挂在"
|
||||
"\n 结构位以内,价格不用回踩到前低就出局 —— 一类实测 88.6%。")
|
||||
|
||||
print("\n【二】按原方向做(抄底/摸顶)")
|
||||
print(pd.DataFrame([{"类型": t, **perf(g)}
|
||||
for t, g in x.groupby("type")
|
||||
if len(g) >= 30]).to_string(index=False))
|
||||
|
||||
print("\n【三】反手做")
|
||||
print(pd.DataFrame([{"类型": t, **perf(g, "f_")}
|
||||
for t, g in x.groupby("type")
|
||||
if len(g) >= 30]).to_string(index=False))
|
||||
|
||||
print("\n【四】二类拆命中/未命中 —— 一类的对应数字是 0.08 vs 0.70")
|
||||
two = x[x.type.isin(["B2", "S2"])]
|
||||
if len(two) >= 60:
|
||||
print(pd.DataFrame([
|
||||
{"命中线段顶点": "是" if k else "否", **perf(g)}
|
||||
for k, g in two.groupby(two.hit) if len(g) >= 20
|
||||
]).to_string(index=False))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
ap.add_argument("--workers", type=int, default=2)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
f"{0 if r is None else len(r)}", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""改识别规则之前,先量机会本身装不装得下确认成本。
|
||||
|
||||
用户问:改 B1/B2 的识别规则行不行?
|
||||
|
||||
「改识别规则」有两种含义,第一种已经被测过上限:
|
||||
(a) 换规则**挑**出更好的 B1 —— step60 用未来函数只留极值落在真线段底 ±2 根内
|
||||
的信号,这是任何识别规则的理论最好情况,PF 仅 0.70~0.83。这一类改法封死。
|
||||
|
||||
但 0.83 这个上限本身是**结果**,背后是一个从没直接测过的量:
|
||||
|
||||
一段线段从真底走到真顶,一共有几个 ATR?而等笔确认要花掉 2.9 个。
|
||||
|
||||
这决定了 (b) 类改法(改**入场时点/构造**,而非改选样)有没有空间:
|
||||
|
||||
线段幅度 4 ATR -> 进场吃掉 2.9,剩 1.1 去扛 2 ATR 止损,**任何规则都救不活**
|
||||
线段幅度 12 ATR -> 2.9 只是小费,值得继续改规则
|
||||
|
||||
注意这测的是**市场**,不是我们的检测器:标准答案直接取 `seg_list` 的真实端点,
|
||||
完全不经过 `find_all_bsp`。所以结论对「换任何一套识别规则」都成立。
|
||||
|
||||
而且这个比值大概率**随级别变化**(ATR 与线段幅度未必同比例缩放),所以跑
|
||||
5m/15m/1h/4h。真正的产出不是「改不改规则」,而是**「在哪个级别上改才有意义」**。
|
||||
|
||||
预算恒等式(每根线段一行):
|
||||
可捕获 = 线段幅度 − 确认成本 需要 > 止损 才有正期望的可能
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step65_room.feather"
|
||||
SL_ATR = 2.0
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 3_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
segs = [s for s in (getattr(chan, "seg_list", []) or [])
|
||||
if s.end_time is not None and s.start_time is not None]
|
||||
if len(segs) < 20:
|
||||
return None
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
hi, lo = cdf["high"].to_numpy(float), cdf["low"].to_numpy(float)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
rec = []
|
||||
for sg in segs:
|
||||
i0, i1 = to_i(sg.start_time), to_i(sg.end_time)
|
||||
if not (0 <= i0 < i1 < n) or i1 - i0 < 2:
|
||||
continue
|
||||
a = atr[i0]
|
||||
if not np.isfinite(a) or a <= 0:
|
||||
continue
|
||||
up = sg.dir != Chan_SEG_DIR.DOWN
|
||||
# 从线段起点(上一段的真实反转点)到终点的幅度
|
||||
span = ((hi[i0:i1 + 1].max() - lo[i0]) if up
|
||||
else (hi[i0] - lo[i0:i1 + 1].min()))
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "dir": 1 if up else -1,
|
||||
"bars": i1 - i0,
|
||||
"span_atr": span / a,
|
||||
"atr_pct": a / float(cdf["close"].to_numpy(float)[i0]),
|
||||
})
|
||||
return pd.DataFrame(rec) if rec else None
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
TFO = {"5m": 0, "15m": 1, "1h": 2, "4h": 3}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame, cost: float) -> None:
|
||||
print("\n" + "=" * 96)
|
||||
print(f"【一】线段幅度 vs 确认成本(成本按一类实测的 {cost} ATR 计)")
|
||||
print("=" * 96)
|
||||
rows = []
|
||||
for tf, x in sorted(d.groupby("tf"), key=lambda kv: TFO.get(kv[0], 9)):
|
||||
q = x.span_atr.quantile([.25, .5, .75]).values
|
||||
room = x.span_atr - cost
|
||||
rows.append({
|
||||
"级别": tf, "线段数": len(x),
|
||||
"幅度Q1": round(q[0], 1), "幅度中位": round(q[1], 1),
|
||||
"幅度Q3": round(q[2], 1),
|
||||
"确认后剩余(中位)": round(q[1] - cost, 1),
|
||||
f"剩余>止损{SL_ATR}": f"{(room > SL_ATR).mean()*100:.0f}%",
|
||||
"剩余/止损": round((q[1] - cost) / SL_ATR, 2),
|
||||
"中位时长(根)": int(x.bars.median()),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print(f"""
|
||||
「剩余/止损」是这张表的结论行:真底进场、扣掉确认成本后,还剩几倍止损的空间。
|
||||
< 1 机会装不下确认成本,**换任何识别规则都没用**
|
||||
1~2 勉强打平,要求选样精度极高(step60 实测最好 30%)
|
||||
> 2 有空间,值得改规则/改入场构造""")
|
||||
|
||||
print("\n【二】把确认成本当变量:多低才够用")
|
||||
rows = []
|
||||
for tf, x in sorted(d.groupby("tf"), key=lambda kv: TFO.get(kv[0], 9)):
|
||||
r = {"级别": tf}
|
||||
for c in (0.0, 1.0, 2.0, 2.9):
|
||||
r[f"成本{c}"] = round((x.span_atr.median() - c) / SL_ATR, 2)
|
||||
rows.append(r)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n 成本 0 = 完美实时(在真底那根就进)。若连成本 0 那列都 < 2,"
|
||||
"\n 说明**不是滞后的问题,是这个级别的线段本身就太小**。")
|
||||
|
||||
print("\n【三】逐币(中位幅度 ATR),看结论是否普适")
|
||||
print(d.pivot_table(index="sym", columns="tf", values="span_atr",
|
||||
aggfunc="median").round(1)
|
||||
.reindex(columns=[t for t in TFO if t in set(d.tf)])
|
||||
.to_string())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m,1h,4h")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
ap.add_argument("--cost", type=float, default=2.9)
|
||||
ap.add_argument("--workers", type=int, default=2)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT), args.cost)
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
f"{0 if r is None else len(r)}", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d, args.cost)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""改识别规则到底有没有用:把天花板一次性测到顶。
|
||||
|
||||
用户问「改 B1/B2 的识别规则呢」。step65 让这个问题变得可判定,但也制造了一个
|
||||
必须解释的矛盾:
|
||||
|
||||
线段中位 11.4 ATR,进场花掉 2.9,剩 8.5 —— 是 2 ATR 止损的 4.26 倍。
|
||||
可 step60 用未来函数选出真底的那批 B1,PF 仍只有 0.70~0.83。
|
||||
|
||||
空间明明在,就是拿不到。两种解释对用户的问题给出**相反**的答案:
|
||||
|
||||
选样问题:只有 30% 的 B1 落在真线段底,那 8.5 ATR 只存在于这 30% 里,
|
||||
其余 70% 是趋势中途,后面根本没有行情 -> **改识别规则有用**
|
||||
路径问题:就算落在真底,11.4 ATR 是净幅度,路上的回撤照样打掉止损
|
||||
-> **改识别规则没用**
|
||||
|
||||
区分只需要一个从没跑过的组合:**未来函数选样 + 结构止损 + 绝对目标**。
|
||||
三样单独都失败过(step60 的 0.83 / step58 的 0.36 / 本轮 abs 的 0.36),
|
||||
组合起来没试过。它测的不是某条规则,是**所有识别规则的上界**:
|
||||
|
||||
上界 < 1.0 -> 路径问题,一类线彻底关闭,别再改规则
|
||||
上界 > 1.5 -> 选样问题,改识别规则有用,且这里就是要够到的目标
|
||||
|
||||
同时量 MFE 做交叉验证:真底那批到底跑没跑出 8.5 ATR。
|
||||
跑出来了却还是亏 -> 铁证是止损/路径
|
||||
根本没跑出来 -> 说明 11.4 ATR 那个测量传导不到这批信号上,step65 要重解释
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
SRC = HERE / "out" / "step64_b2.feather"
|
||||
MAXB = 48
|
||||
|
||||
|
||||
def mfe_mae(cdf: pd.DataFrame, idx: np.ndarray, dirs: np.ndarray,
|
||||
atr: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""入场后 MAXB 根内最远的顺向/逆向行程(ATR)。"""
|
||||
hi, lo = cdf["high"].to_numpy(float), cdf["low"].to_numpy(float)
|
||||
op = cdf["open"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
fe = np.full(len(idx), np.nan)
|
||||
ae = np.full(len(idx), np.nan)
|
||||
for k, (i, d, a) in enumerate(zip(idx, dirs, atr)):
|
||||
e = int(i) + 1
|
||||
if e >= n or not np.isfinite(a) or a <= 0:
|
||||
continue
|
||||
j = min(e + MAXB, n)
|
||||
px = op[e]
|
||||
if d == 1:
|
||||
fe[k] = (hi[e:j].max() - px) / a
|
||||
ae[k] = (px - lo[e:j].min()) / a
|
||||
else:
|
||||
fe[k] = (px - lo[e:j].min()) / a
|
||||
ae[k] = (hi[e:j].max() - px) / a
|
||||
return fe, ae
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
args = ap.parse_args()
|
||||
|
||||
from chanlun import TF_DF
|
||||
from lib.data import fetch_ohlcv
|
||||
from step58_struct_stop import simulate, stats
|
||||
|
||||
d = pd.read_feather(SRC)
|
||||
d = d[(d.tf == args.tf) & d.type.isin(["B1", "S1"])].copy()
|
||||
d["to_ext"] = (d.entry_px - d.own_ext) * d.dir / d.atr_at_entry
|
||||
print(f"[识别规则天花板] {args.tf} · 一类 {len(d)} 笔 · "
|
||||
f"其中落在真线段端点 {d.hit.sum()} 笔 ({d.hit.mean()*100:.1f}%)\n")
|
||||
|
||||
cache = {}
|
||||
for sym in sorted(d.sym.unique()):
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
|
||||
cache[sym] = TF_DF(df, 1, args.tf).dataframe
|
||||
|
||||
print("=" * 96)
|
||||
print("【一】MFE 交叉验证:真底那批到底跑没跑出 step65 说的 8.5 ATR")
|
||||
print("=" * 96)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.hit):
|
||||
fe, ae = [], []
|
||||
for sym, x in g.groupby("sym"):
|
||||
f_, a_ = mfe_mae(cache[sym], x.i_sure.values, x.dir.values,
|
||||
x.atr_at_entry.values)
|
||||
fe.append(f_)
|
||||
ae.append(a_)
|
||||
fe, ae = np.concatenate(fe), np.concatenate(ae)
|
||||
ok = np.isfinite(fe)
|
||||
rows.append({
|
||||
"落在真线段端点": "是" if k else "否", "笔数": int(ok.sum()),
|
||||
"MFE中位": round(float(np.median(fe[ok])), 2),
|
||||
"MFE≥3ATR": f"{(fe[ok] >= 3).mean()*100:.0f}%",
|
||||
"MFE≥8ATR": f"{(fe[ok] >= 8).mean()*100:.0f}%",
|
||||
"MAE中位": round(float(np.median(ae[ok])), 2),
|
||||
"MAE≥2ATR(会被现止损打掉)": f"{(ae[ok] >= 2).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
判读:若「是」那行 MFE 中位远低于 8.5,说明 step65 的线段幅度传导不到
|
||||
信号上(进场时行情已经走掉了大半);若 MFE 够大而 MAE 也大,则是路径问题。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【二】天花板:未来函数选样 + 结构止损 + 绝对目标(三者首次组合)")
|
||||
print("=" * 96)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.hit):
|
||||
for label, margin in [("固定2ATR", None), ("结构+0.5", 0.5),
|
||||
("结构+1.0", 1.0), ("结构+1.5", 1.5)]:
|
||||
gs, rs, cs, ap_, sl_ = [], [], [], [], []
|
||||
for sym, x in g.groupby("sym"):
|
||||
x = x.reset_index(drop=True)
|
||||
t = pd.DataFrame({"entry_idx": x.i_sure.values,
|
||||
"direction": x.dir.values,
|
||||
"atr_at_entry": x.atr_at_entry.values})
|
||||
sl = (np.full(len(x), 2.0) if margin is None
|
||||
else (x.to_ext.values + margin))
|
||||
res = simulate(cache[sym], t, sl, r_scale=False)
|
||||
ok = res.g.notna().values
|
||||
gs.append(res.g[ok])
|
||||
rs.append(res.r[ok])
|
||||
cs.append(res.c[ok])
|
||||
ap_.append((x.atr_at_entry.values / x.entry_px.values)[ok])
|
||||
sl_.append(sl[ok])
|
||||
rows.append({
|
||||
"落在真线段端点": "是" if k else "否", "止损": label,
|
||||
**stats(pd.concat(gs, ignore_index=True),
|
||||
pd.concat(rs, ignore_index=True),
|
||||
pd.concat(cs, ignore_index=True),
|
||||
np.concatenate(ap_), np.concatenate(sl_))})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
「是」那几行就是**任何识别规则的上界**(因为选样已经用了未来函数):
|
||||
< 1.0 路径问题,一类线关闭,改规则无解
|
||||
> 1.5 选样问题,改识别规则有用,且这就是要够到的目标""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""把 step66 的天花板换成实时可算的选样,看能兑现多少。
|
||||
|
||||
step66 测出上界:**未来函数选样 + 结构止损**在 5m 是 PF 2.29、15m 是 4.11。
|
||||
但那个选样用了线段端点(事后才知道),不能交易。本脚本把它换成 step62 那个
|
||||
**实时可算**的 `ext_run`(极值越过中枢边界几个 ATR,当根即可算),配上结构止损。
|
||||
|
||||
这是唯一同时处理两个已知约束的组合,也是 §3.3992 列出的第一个待跑实验:
|
||||
|
||||
step62 有实时过滤器(精度 28.7% -> 47.9%)但配了 2 ATR 止损 -> PF 0.54
|
||||
step58 有结构止损但没有过滤器 -> PF 0.36
|
||||
step66 两个都有,但选样是未来函数 -> PF 2.29 / 4.11
|
||||
**本脚本:实时过滤器 + 结构止损** -> ?
|
||||
|
||||
判读(对照 step66 的上界):
|
||||
> 1.3 兑现了相当部分,一类线值得继续,下一步做因果回放(sure_time 仍是未来函数)
|
||||
~ 1.0 过滤器精度不够,需要更好的实时特征
|
||||
< 0.8 实时特征抓不到那 28.7%,天花板兑现不了,一类线仍关闭
|
||||
|
||||
⚠️ 即使 > 1.3 也**不能算数**:入场仍在 `sure_time` 上,而 §3.396 证明实盘回放
|
||||
还要再晚 15 根。这一步只决定「值不值得再花一次因果回放的机器时间」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
F62 = HERE / "out" / "step62_trend_end.feather"
|
||||
F64 = HERE / "out" / "step64_b2.feather"
|
||||
KEY = ["sym", "tf", "type", "i_ext", "i_sure"]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
args = ap.parse_args()
|
||||
|
||||
from chanlun import TF_DF
|
||||
from lib.data import fetch_ohlcv
|
||||
from step58_struct_stop import simulate, stats
|
||||
|
||||
a = pd.read_feather(F62)[KEY + ["ext_run", "div", "ladder_n"]]
|
||||
b = pd.read_feather(F64)[KEY + ["entry_px", "own_ext",
|
||||
"atr_at_entry", "hit", "dir"]]
|
||||
d = a.merge(b, on=KEY, how="inner")
|
||||
d = d[d.tf == args.tf].reset_index(drop=True)
|
||||
d["to_ext"] = (d.entry_px - d.own_ext) * d.dir / d.atr_at_entry
|
||||
|
||||
print(f"[实时过滤器 + 结构止损] {args.tf} · 一类 {len(d)} 笔 · "
|
||||
f"真线段端点占比 {d.hit.mean()*100:.1f}%")
|
||||
print(f"step66 上界(同止损、但用未来函数选样):"
|
||||
f"{'PF 2.29' if args.tf == '5m' else 'PF 4.11'}\n")
|
||||
|
||||
cache = {}
|
||||
for sym in sorted(d.sym.unique()):
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
|
||||
cache[sym] = TF_DF(df, 1, args.tf).dataframe
|
||||
|
||||
def run(x: pd.DataFrame, margin: float | None) -> dict:
|
||||
gs, rs, cs, ap_, sl_ = [], [], [], [], []
|
||||
for sym, g in x.groupby("sym"):
|
||||
g = g.reset_index(drop=True)
|
||||
t = pd.DataFrame({"entry_idx": g.i_sure.values,
|
||||
"direction": g.dir.values,
|
||||
"atr_at_entry": g.atr_at_entry.values})
|
||||
sl = (np.full(len(g), 2.0) if margin is None
|
||||
else (g.to_ext.values + margin))
|
||||
res = simulate(cache[sym], t, sl, r_scale=False)
|
||||
ok = res.g.notna().values
|
||||
gs.append(res.g[ok])
|
||||
rs.append(res.r[ok])
|
||||
cs.append(res.c[ok])
|
||||
ap_.append((g.atr_at_entry.values / g.entry_px.values)[ok])
|
||||
sl_.append(sl[ok])
|
||||
return stats(pd.concat(gs, ignore_index=True),
|
||||
pd.concat(rs, ignore_index=True),
|
||||
pd.concat(cs, ignore_index=True),
|
||||
np.concatenate(ap_), np.concatenate(sl_))
|
||||
|
||||
print("=" * 96)
|
||||
print("【一】ext_run 分档 × 止损口径 —— 过滤器的效果依赖止损吗")
|
||||
print("=" * 96)
|
||||
q = d.ext_run.quantile([.25, .5, .75]).values
|
||||
lab = ["Q1最短", "Q2", "Q3", "Q4最延伸"]
|
||||
d["bucket"] = pd.cut(d.ext_run, [-np.inf, *q, np.inf], labels=lab)
|
||||
rows = []
|
||||
for bk, g in d.groupby("bucket", observed=True):
|
||||
for name, m in [("固定2ATR", None), ("结构+1.0", 1.0)]:
|
||||
rows.append({"ext_run档": bk, "止损": name, "真端点占比":
|
||||
f"{g.hit.mean()*100:.0f}%", **run(g, m)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
§3.399 判过「过滤器符号随入场时点翻转 -> 拟合」。这里换的是**止损**而非入场,
|
||||
若 Q4 在两种止损下都最好,说明 ext_run 是真信号;若又翻转,则仍是拟合。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【二】实时可交易组合 vs step66 上界")
|
||||
print("=" * 96)
|
||||
rows = [{"选样": "全体(无过滤)", "笔数": len(d), **run(d, 1.0)}]
|
||||
for p in (50, 75):
|
||||
thr = np.percentile(d.ext_run, p)
|
||||
g = d[d.ext_run >= thr]
|
||||
rows.append({"选样": f"ext_run ≥ P{p}(实时)", "笔数": len(g),
|
||||
**run(g, 1.0)})
|
||||
g = d[(d.ext_run >= np.percentile(d.ext_run, 75))
|
||||
& (d["div"] >= d["div"].median())]
|
||||
rows.append({"选样": "ext_run≥P75 且 div≥中位(实时)",
|
||||
"笔数": len(g), **run(g, 1.0)})
|
||||
gh = d[d.hit]
|
||||
rows.append({"选样": "★真线段端点(未来函数=上界)", "笔数": len(gh),
|
||||
**run(gh, 1.0)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
判读:实时行若接近 ★ 那行,说明 ext_run 抓到了同一批信号,一类线值得继续;
|
||||
若仍贴近「全体」,说明实时特征抓不到那 28.7%,天花板兑现不了。
|
||||
⚠️ 即便好也不算数——入场仍在 sure_time 上,必须再过一次因果回放。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【三】数字对不上,追一下:ext_run 挑出的真端点,质量还一样吗")
|
||||
print("=" * 96)
|
||||
print("Q4 的真端点浓度 44%(基线 28.7%),若真端点都值 PF 2.29,"
|
||||
"Q4 不该只有 0.51。\n拆开看 ext_run 在真端点**内部**是帮忙还是帮倒忙——"
|
||||
"这决定天花板是否可学:")
|
||||
rows = []
|
||||
for k, g in d.groupby(d.hit):
|
||||
for bk, gg in g.groupby("bucket", observed=True):
|
||||
if len(gg) < 25:
|
||||
continue
|
||||
rows.append({"真端点": "是" if k else "否", "ext_run档": bk,
|
||||
"笔数": len(gg), **run(gg, 1.0)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
「是」组内若 Q4 明显低于 Q1 -> ext_run 在真端点里**反向选样**,
|
||||
它提高浓度的同时挑走了最差的那些,两个效应抵消,这解释了 0.51 vs 2.29。
|
||||
若「是」组内各档持平 -> 浓度提升是真的,缺的只是更强的实时特征。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【四】那需要多高的实时精度才能翻正")
|
||||
print("=" * 96)
|
||||
print("【三】显示只有「是不是真端点」这一个变量在起作用(组内各档持平)。"
|
||||
"\n于是 PF 只是两组按精度 p 的混合,可以直接解出盈亏平衡精度:")
|
||||
net = {}
|
||||
for k, g in d.groupby(d.hit):
|
||||
ns = []
|
||||
for sym, x in g.groupby("sym"):
|
||||
x = x.reset_index(drop=True)
|
||||
t = pd.DataFrame({"entry_idx": x.i_sure.values,
|
||||
"direction": x.dir.values,
|
||||
"atr_at_entry": x.atr_at_entry.values})
|
||||
res = simulate(cache[sym], t, x.to_ext.values + 1.0,
|
||||
r_scale=False)
|
||||
ok = res.g.notna().values
|
||||
from lib.exit_model import fee_of
|
||||
ns.append(res.g[ok].values
|
||||
- fee_of(res.r[ok].values, res.c[ok].values))
|
||||
net[bool(k)] = np.concatenate(ns)
|
||||
|
||||
def mix_pf(p: float) -> float:
|
||||
"""精度 p 时的 PF:两组按 p 加权(组内分布不变,只变权重)。"""
|
||||
h, m = net[True], net[False]
|
||||
w = (p * h[h > 0].sum() / len(h)
|
||||
+ (1 - p) * m[m > 0].sum() / len(m))
|
||||
l = (p * -h[h <= 0].sum() / len(h)
|
||||
+ (1 - p) * -m[m <= 0].sum() / len(m))
|
||||
return w / l if l > 0 else np.inf
|
||||
|
||||
grid = [0.287, 0.35, 0.44, 0.5, 0.6, 0.7, 0.8, 1.0]
|
||||
print(pd.DataFrame([{
|
||||
"实时精度": f"{p*100:.1f}%", "PF": round(mix_pf(p), 2),
|
||||
"备注": {0.287: "← 当前基线", 0.44: "← ext_run Q4 已达到",
|
||||
1.0: "← step66 上界"}.get(p, "")} for p in grid]
|
||||
).to_string(index=False))
|
||||
lo, hi = 0.287, 1.0
|
||||
if mix_pf(hi) > 1 > mix_pf(lo):
|
||||
for _ in range(40):
|
||||
mid = (lo + hi) / 2
|
||||
lo, hi = (mid, hi) if mix_pf(mid) < 1 else (lo, mid)
|
||||
print(f"\n **盈亏平衡精度 ≈ {(lo+hi)/2*100:.1f}%** "
|
||||
f"(当前 28.7%,ext_run Q4 已到 44.0%)")
|
||||
print(" 这就是「改识别规则」要够到的具体门槛,且它只是毛平衡;"
|
||||
"\n 还要再扣 §3.396 的因果滞后(实盘比 sure_time 再晚 15 根)。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,265 @@
|
||||
"""按**实盘口径**重放 B4,并测三道过滤能否刷掉那批亏钱的额外信号。
|
||||
|
||||
step63 的结论是一好一坏:
|
||||
好 时点干净 —— 100% 召回、100% 准时、零滞后(一类是 0% 准时、+15 根)
|
||||
坏 回放多出 592 个全量口径没有的信号,PF 0.46 / t −5.03,混合后 0.64 < 1
|
||||
|
||||
但 step63 有两个口径问题,本脚本一并修掉:
|
||||
|
||||
① 窗口不对。回测用全量 45000 根一次算完,step63 用 2 万涨到 4 万根的增长窗口,
|
||||
**而实盘用 2001 根滚动窗口、每 500 根 init_stream 拉回**(`shadow_signal.py`
|
||||
的 MAX_GROW)。三种口径的中枢结构都不一样。这也是 HANDOFF 里挂着的
|
||||
「回测用全量历史建中枢、实盘用 2000 根窗口」那条待办。
|
||||
顺带:窗口封顶后单步成本恒定,不再是 step63 那个平方级(143ms@2万根 ->
|
||||
292ms@4万根),所以本脚本快得多。
|
||||
|
||||
② 没测过滤。step63 跑的是裸信号,而实盘有三道滤网。ATR 门控已单独测过——
|
||||
它刷掉 7.9% 的额外信号却刷掉 10.7% 的好信号,PF 纹丝不动。剩下两道要测。
|
||||
|
||||
**一处刻意的简化,方向是保守的**:大级别分型时间线用全量历史算(真实盘的 HTF
|
||||
也会重画)。这等于**给同向过滤器开了未来函数的后门**。若连这样都刷不掉额外信号,
|
||||
结论只会更强。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step68_live_window.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
WIN, MAX_GROW, GATE_BP = 2001, 500, 8.0
|
||||
|
||||
|
||||
def _ladder(zones: pd.DataFrame) -> pd.DataFrame:
|
||||
z = zones.copy()
|
||||
pg, pdn = z["zg"].shift(), z["zd"].shift()
|
||||
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
|
||||
z["zone_i"] = np.arange(len(z))
|
||||
return z
|
||||
|
||||
|
||||
def replay(sym: str, ltf: str, htf: str, rows: int,
|
||||
steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.fx_signal import extract_fx_signals, signals_to_frame
|
||||
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
|
||||
if df is None or len(df) < WIN + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, ltf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zs_full = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
if not zs_full:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zs_full, cdf))
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
|
||||
# 大级别分型时间线:全量算(见模块 docstring 的「刻意简化」)
|
||||
dh = fetch_ohlcv(f"{sym}/USDT:USDT", htf, rows)
|
||||
tl = None
|
||||
if dh is not None and len(dh) > 500:
|
||||
ch = TF_DF(dh, 1, htf)
|
||||
tl = htf_fx_timeline(
|
||||
signals_to_frame(extract_fx_signals(ch, ch.dataframe)),
|
||||
ch.dataframe)
|
||||
|
||||
rec: dict[tuple, dict] = {}
|
||||
chan = None
|
||||
anchor = 0
|
||||
for i in range(WIN, len(df)):
|
||||
# 实盘的窗口纪律:2001 根起,长过 MAX_GROW 就 init_stream 拉回
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, ltf)
|
||||
chan.init_stream(w, 1, ltf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = _ladder(zones_from_zs_list(zl, sub))
|
||||
if z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last] # 实盘只做当根
|
||||
if s.empty:
|
||||
continue
|
||||
if "zone_i" in s.columns:
|
||||
s = s.merge(z[["zone_i", "z_above", "z_below"]],
|
||||
on="zone_i", how="left")
|
||||
if tl is not None:
|
||||
s = attach_htf_context(s, sub, tl, "h1")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (i, int(r.direction))
|
||||
if k in rec:
|
||||
continue
|
||||
push = getattr(r, "z_above" if r.direction == 1
|
||||
else "z_below", None)
|
||||
ag = getattr(r, "h1_agree", 0)
|
||||
rec[k] = {
|
||||
"sym": sym, "entry_idx": i, "direction": int(r.direction),
|
||||
"in_full": (i, int(r.direction)) in full_keys,
|
||||
"ladder_ok": int(bool(pd.notna(push) and bool(push))),
|
||||
"h1_agree": int(ag) if pd.notna(ag) else 0,
|
||||
}
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(list(rec.values()))
|
||||
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
r = r[(r.entry_idx < len(cdf) - 2)
|
||||
& np.isfinite(atr[r.entry_idx.values])
|
||||
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.entry_idx.values,
|
||||
"direction": r.direction.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c"):
|
||||
r[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
|
||||
r["gate_ok"] = (r.atr_pct * 1e4 >= GATE_BP).astype(int)
|
||||
r["pass_all"] = ((r.h1_agree == 1) & (r.ladder_ok == 1)
|
||||
& (r.gate_ok == 1)).astype(int)
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict | None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
if len(g) < 20:
|
||||
return None
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
print("\n" + "=" * 92)
|
||||
print("【一】实盘窗口下还有多少额外信号")
|
||||
print("=" * 92)
|
||||
print(f"回放信号 {len(d)} · 其中全量口径也有 {int(d.in_full.sum())} · "
|
||||
f"**额外 {int((~d.in_full).sum())}**")
|
||||
print(f"(step63 的增长窗口口径:197 / 592)")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【二】三道过滤能不能刷掉额外信号 —— 这决定实盘是否在亏钱")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for nm, m in [("① 无过滤", None), ("② 仅同向", d.h1_agree == 1),
|
||||
("③ 仅阶梯", d.ladder_ok == 1),
|
||||
("④ 仅ATR门控", d.gate_ok == 1),
|
||||
("⑤ 三道全开(实盘口径)", d.pass_all == 1)]:
|
||||
x = d if m is None else d[m]
|
||||
if x.empty:
|
||||
continue
|
||||
ex, bt = x[~x.in_full], x[x.in_full]
|
||||
row = {"过滤": nm, "留下": len(x),
|
||||
"额外占比": f"{(~x.in_full).mean()*100:.0f}%"}
|
||||
for lab, g in [("全部", x), ("额外", ex), ("回测口径", bt)]:
|
||||
s = perf(g)
|
||||
row[f"{lab}PF"] = "—" if s is None else s["PF"]
|
||||
row[f"{lab}n"] = len(g)
|
||||
rows.append(row)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【三】实盘口径(三道全开)的完整表现")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for nm, g in [("实盘会做的全部", d[d.pass_all == 1]),
|
||||
(" 其中额外的", d[(d.pass_all == 1) & ~d.in_full]),
|
||||
(" 其中回测也有的", d[(d.pass_all == 1) & d.in_full])]:
|
||||
s = perf(g)
|
||||
if s:
|
||||
rows.append({"分组": nm, **s})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
判读:「实盘会做的全部」PF > 1 -> 实盘安全,额外信号被滤网挡住了
|
||||
PF < 1 -> **实盘在做一批回测里不存在、且亏钱的信号**,要立刻处理""")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--ltf", default="5m")
|
||||
ap.add_argument("--htf", default="30m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[实盘口径回放] {len(syms)} 币 × {args.steps} 根 · "
|
||||
f"{WIN} 根滚动窗口 / 每 {MAX_GROW} 根重建\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.ltf, args.htf, args.rows,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
f"{0 if r is None else len(r)} 个信号", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""那 72~77% 的「额外信号」到底怎么来的:中枢重画,还是扫描窗口错位?
|
||||
|
||||
我在 §3.3994 里把它归因为「中枢重画」,**这个归因没验证过,而且与已有结论矛盾**
|
||||
(step39 的假阳性 0%、`verify_window_sens` 的窗口 +200/+500/+1000 逐字段一致)。
|
||||
用户指出中枢不重画,代码注释也支持他:
|
||||
|
||||
# -1 = 最后一笔(历史默认)。中枢每吸收一根K线,最后一笔就可能后移,
|
||||
# 于是 available_ts 跟着漂——这是右边缘重画的根因
|
||||
—— chanlun/analysis/fast_bsp.py:47
|
||||
|
||||
漂的不是中枢**边界**(zg/zd),是它的**可用时刻**。而 `find_fast_bsp3` 只从
|
||||
`available_ts` 往后扫 `scan=200` 根。两种口径的窗口因此错位:
|
||||
|
||||
实时 中枢没吸收完,available_ts 偏早 -> 窗口开得早
|
||||
全量 中枢吸收完了,available_ts 偏晚(§5.41 实测中位晚 62 分钟)-> 窗口开得晚
|
||||
|
||||
落在「实时窗口内、全量窗口外」的信号,全量根本没扫到那个时段,于是显示为「额外」。
|
||||
|
||||
两种机制的修法完全不同,所以必须分清:
|
||||
|
||||
边界重画 结构本身不稳,只能用滞后换稳定性,代价大
|
||||
窗口错位 中枢是同一个真中枢,信号也是真信号,只是**开得太早、确认不足**
|
||||
—— 这正好解释它们为什么亏(PF 0.26~0.36),且修法是调 available_ts
|
||||
|
||||
判据:逐个额外信号,去全量中枢表里按 (zg, zd) 找它的中枢。
|
||||
找得到且边界一致 -> 窗口错位(用户是对的,我的归因错了)
|
||||
找不到或边界不同 -> 边界重画(我的归因成立)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step69_mech.feather"
|
||||
WIN, MAX_GROW, SCAN = 2001, 500, 200
|
||||
TOL = 1e-6
|
||||
|
||||
|
||||
def replay(sym: str, ltf: str, rows: int, steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
|
||||
if df is None or len(df) < WIN + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, ltf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zf = zones_from_zs_list(full.cal_bi_zs_list_pure(full.bi_list), cdf)
|
||||
if zf is None or zf.empty:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zf)
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
fzg = zf["zg"].to_numpy(float)
|
||||
fzd = zf["zd"].to_numpy(float)
|
||||
fav = zf["available_ts"].to_numpy()
|
||||
ts_all = cdf["timestamp"].to_numpy()
|
||||
|
||||
rec: dict[tuple, dict] = {}
|
||||
chan, anchor = None, 0
|
||||
for i in range(WIN, len(df)):
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, ltf)
|
||||
chan.init_stream(w, 1, ltf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z is None or z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last]
|
||||
if s.empty or "zone_i" not in s.columns:
|
||||
continue
|
||||
# find_fast_bsp3 的输出自带 zg/zd,直接 merge 会加后缀,
|
||||
# 所以中枢侧的列全部改名再接
|
||||
zc = z[["zg", "zd", "available_ts"]].rename(columns={
|
||||
"zg": "z_zg", "zd": "z_zd", "available_ts": "z_av"})
|
||||
zc["zone_i"] = np.arange(len(z))
|
||||
s = s.drop(columns=[c for c in ("z_zg", "z_zd", "z_av")
|
||||
if c in s.columns])
|
||||
s = s.merge(zc, on="zone_i", how="left")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (i, int(r.direction))
|
||||
if k in rec:
|
||||
continue
|
||||
# 该中枢在全量表里是否存在(按边界匹配,边界是不该漂的量)
|
||||
zg_, zd_ = float(r.z_zg), float(r.z_zd)
|
||||
m = (np.abs(fzg - zg_) <= TOL * max(1.0, abs(zg_))) \
|
||||
& (np.abs(fzd - zd_) <= TOL * max(1.0, abs(zd_)))
|
||||
j = int(np.argmax(m)) if m.any() else -1
|
||||
rec[k] = {
|
||||
"sym": sym, "entry_idx": i, "direction": int(r.direction),
|
||||
"in_full": k in full_keys,
|
||||
"zone_found": bool(m.any()),
|
||||
"z_zg": zg_, "z_zd": zd_,
|
||||
"avail_rt": int(r.z_av),
|
||||
"avail_full": int(fav[j]) if j >= 0 else -1,
|
||||
"ts_entry": int(ts_all[i]) if i < len(ts_all) else -1,
|
||||
}
|
||||
return pd.DataFrame(list(rec.values())) if rec else None
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame, ltf: str) -> None:
|
||||
ex = d[~d.in_full]
|
||||
print("\n" + "=" * 92)
|
||||
print("【一】判据:额外信号所在的中枢,在全量表里找得到吗")
|
||||
print("=" * 92)
|
||||
print(f"回放信号 {len(d)} · 额外 {len(ex)}")
|
||||
print(f"**额外信号中,其中枢按 (zg,zd) 在全量表里找得到的:"
|
||||
f"{ex.zone_found.mean()*100:.1f}%**")
|
||||
print(f"(对照:非额外信号 {d[d.in_full].zone_found.mean()*100:.1f}%)")
|
||||
print("""
|
||||
≈100% -> 中枢边界没变,是**扫描窗口错位**,用户对、我的「重画」归因错
|
||||
明显偏低 -> 中枢确实消失或改边界,「重画」成立""")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【一b】数量级对账:中枢层面只有 6.5% 被改,信号层面却 74% 是额外的")
|
||||
print("=" * 92)
|
||||
print("§5.41 的 A/B 实测:`bis[-1]` 口径下确认时刻被改 6.5%、中枢消失 9.0%。")
|
||||
print("若信号层面的 74% 成立,必然有放大机制。怀疑是 `max_per_zone=1`:")
|
||||
print(" 每个中枢只返回**第一个**入场点,而扫描起点随 available_ts 棘轮后移,")
|
||||
print(" 越过旧入场点后,同一中枢会重新产出一个「第一个」——全量只用最终值,")
|
||||
print(" 所以每中枢至多一个信号,实时却能反复触发。")
|
||||
for nm, x in [("额外信号", ex), ("非额外信号", d[d.in_full])]:
|
||||
if x.empty:
|
||||
continue
|
||||
nz = x.groupby(["sym", "z_zg", "z_zd"]).size() \
|
||||
if "z_zg" in x.columns else None
|
||||
if nz is None:
|
||||
print(" (缺 z_zg/z_zd 列,跳过)")
|
||||
break
|
||||
print(f"\n{nm}:{len(x)} 个信号,落在 {len(nz)} 个不同中枢上 "
|
||||
f"-> 每中枢 {len(x)/len(nz):.2f} 次")
|
||||
print(f" 同一中枢触发次数分布 中位 {nz.median():.0f} "
|
||||
f"P90 {nz.quantile(.9):.0f} 最大 {nz.max()}")
|
||||
print("""
|
||||
若「额外信号」的每中枢次数显著 > 1 而「非额外」≈ 1,放大机制坐实:
|
||||
6.5% 的中枢改动通过棘轮重扫,放大成信号层面的几百个。""")
|
||||
|
||||
g = ex[ex.zone_found & (ex.avail_full > 0)].copy()
|
||||
if g.empty:
|
||||
return
|
||||
bar_ms = {"1m": 60_000, "5m": 300_000, "15m": 900_000}.get(ltf, 300_000)
|
||||
g["drift_bars"] = (g.avail_full - g.avail_rt) / bar_ms
|
||||
g["from_rt"] = (g.ts_entry - g.avail_rt) / bar_ms
|
||||
g["from_full"] = (g.ts_entry - g.avail_full) / bar_ms
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【二】available_ts 漂了多少,以及入场落在谁的扫描窗口里")
|
||||
print("=" * 92)
|
||||
print(f"avail 漂移(全量 − 实时,根) 中位 {g.drift_bars.median():.0f} "
|
||||
f"P25 {g.drift_bars.quantile(.25):.0f} "
|
||||
f"P75 {g.drift_bars.quantile(.75):.0f}")
|
||||
print(f" §5.41 记的是中位晚 62 分钟,本表 {ltf} 下即 "
|
||||
f"{62*60_000/bar_ms:.0f} 根,可交叉验证")
|
||||
print(f"\n入场距实时 avail(根) 中位 {g.from_rt.median():.0f} "
|
||||
f"(应落在 0~{SCAN} 内,否则实时也扫不到)")
|
||||
print(f"入场距全量 avail(根) 中位 {g.from_full.median():.0f}")
|
||||
out = ((g.from_full < 0) | (g.from_full > SCAN)).mean()
|
||||
print(f"\n**入场落在全量扫描窗口 [0,{SCAN}] 之外的比例:{out*100:.1f}%**")
|
||||
print("""
|
||||
这是机制的直接证据:比例高 -> 全量根本没扫到那个时段,所以「没有」这个信号,
|
||||
与中枢是否重画无关。其中 from_full < 0 表示入场早于全量的可用时刻——
|
||||
即**实时抢跑了**,中枢还没吸收完就下单。""")
|
||||
early = (g.from_full < 0).mean()
|
||||
print(f" 其中抢跑(早于全量 avail):{early*100:.1f}%")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--ltf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT), args.ltf)
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[机制判定] {len(syms)} 币 × {args.steps} 根 · {args.ltf}\n",
|
||||
flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex_:
|
||||
fut = {ex_.submit(replay, s, args.ltf, args.rows, args.steps): s
|
||||
for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
f"{0 if r is None else len(r)}", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d, args.ltf)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,280 @@
|
||||
"""把 B4 按「中枢末笔是否已确认」拆开——之前的统计把两种混在一起了。
|
||||
|
||||
用户指出:B4 有两种,**没确认的会出现然后消失,确认的不会**。而我在 step63/68
|
||||
里统计的是 `find_fast_bsp3` 的全部输出,它的返回列里根本没有确认标志
|
||||
(entry_idx/direction/bo_idx/pb_idx/lag/depth/zg/zd/width_pct/occ/zone_i),
|
||||
**两种被混在一起了**,所以「额外信号 74%」这个数字不能直接拿来说实盘。
|
||||
|
||||
确认状态在更上游,`zones_from_zs_list`:
|
||||
|
||||
sure_key = str(getattr(key_bi, "sure_time", "") or "")
|
||||
end_key = str(getattr(key_bi, "end_time", "") or "")
|
||||
avail = ts_of.get(sure_key) or ts_of.get(end_key) # ← 静默退回 end_time
|
||||
|
||||
`ChanBI` 初始 `is_sure=False / sure_time=None`,确认时才 `set_is_sure(True, ...)`。
|
||||
所以**末笔未确认时 available_ts 退回 end_time,而 end_time 随笔延伸而移动**,
|
||||
中枢的可用时刻跟着漂 —— 这正是「出现然后消失」的那一种。笔一旦确认,
|
||||
`sure_time` 固定,中枢不再动。
|
||||
|
||||
这也解释了 §5.41 的数量级:中枢层面确认时刻只被改 6.5%,而我在信号层面看到 74%。
|
||||
|
||||
本脚本按 `zs.bi_list[-1].is_sure` 把信号拆成两组,分别看:
|
||||
额外率 未确认组应显著高(会出现然后消失),已确认组应接近 0
|
||||
收益 若亏损集中在未确认组,那么修法就是**信号侧加一道 is_sure 门**,
|
||||
而不是动出场参数或放弃 B4
|
||||
|
||||
⚠️ 同时要查的第二件事:**实盘路径到底交易哪一种。** `shadow_signal.py` 的三道
|
||||
滤网是同向 + 阶梯 + ATR 门控,**没有 is_sure 这一道**。若未确认组确实是亏损源,
|
||||
且实盘没有挡它,那这道门就是要补的东西。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step70_sure.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
WIN, MAX_GROW, GATE_BP = 2001, 500, 8.0
|
||||
|
||||
|
||||
def _sure_map(zs_list) -> dict:
|
||||
"""(zg, zd) -> 末笔是否已确认。zones_from_zs_list 会按 available_ts 重排,
|
||||
索引对不上,所以用中枢边界当键接回去。"""
|
||||
m = {}
|
||||
for zs in zs_list:
|
||||
bis = getattr(zs, "bi_list", []) or []
|
||||
if not bis:
|
||||
continue
|
||||
m[(round(float(zs.zg), 10), round(float(zs.zd), 10))] = \
|
||||
bool(getattr(bis[-1], "is_sure", False))
|
||||
return m
|
||||
|
||||
|
||||
def replay(sym: str, ltf: str, htf: str, rows: int,
|
||||
steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.fx_signal import extract_fx_signals, signals_to_frame
|
||||
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
|
||||
if df is None or len(df) < WIN + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, ltf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zsf = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
if not zsf:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zsf, cdf))
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
|
||||
dh = fetch_ohlcv(f"{sym}/USDT:USDT", htf, rows)
|
||||
tl = None
|
||||
if dh is not None and len(dh) > 500:
|
||||
ch = TF_DF(dh, 1, htf)
|
||||
tl = htf_fx_timeline(
|
||||
signals_to_frame(extract_fx_signals(ch, ch.dataframe)),
|
||||
ch.dataframe)
|
||||
|
||||
rec: dict[tuple, dict] = {}
|
||||
chan, anchor = None, 0
|
||||
for i in range(WIN, len(df)):
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, ltf)
|
||||
chan.init_stream(w, 1, ltf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z is None or z.empty:
|
||||
continue
|
||||
sm = _sure_map(zl)
|
||||
pg, pdn = z["zg"].shift(), z["zd"].shift()
|
||||
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
|
||||
z["zone_i"] = np.arange(len(z))
|
||||
z["z_sure"] = [
|
||||
sm.get((round(float(a), 10), round(float(b), 10)), False)
|
||||
for a, b in zip(z["zg"], z["zd"])]
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last]
|
||||
if s.empty or "zone_i" not in s.columns:
|
||||
continue
|
||||
s = s.merge(z[["zone_i", "z_above", "z_below", "z_sure"]],
|
||||
on="zone_i", how="left")
|
||||
if tl is not None:
|
||||
s = attach_htf_context(s, sub, tl, "h1")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (i, int(r.direction))
|
||||
if k in rec:
|
||||
continue
|
||||
push = getattr(r, "z_above" if r.direction == 1
|
||||
else "z_below", None)
|
||||
ag = getattr(r, "h1_agree", 0)
|
||||
rec[k] = {
|
||||
"sym": sym, "entry_idx": i, "direction": int(r.direction),
|
||||
"in_full": k in full_keys,
|
||||
"z_sure": bool(getattr(r, "z_sure", False)),
|
||||
"ladder_ok": int(bool(pd.notna(push) and bool(push))),
|
||||
"h1_agree": int(ag) if pd.notna(ag) else 0,
|
||||
}
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(list(rec.values()))
|
||||
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
r = r[(r.entry_idx < len(cdf) - 2)
|
||||
& np.isfinite(atr[r.entry_idx.values])
|
||||
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.entry_idx.values,
|
||||
"direction": r.direction.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c"):
|
||||
r[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
|
||||
r["gate_ok"] = (r.atr_pct * 1e4 >= GATE_BP).astype(int)
|
||||
r["pass_all"] = ((r.h1_agree == 1) & (r.ladder_ok == 1)
|
||||
& (r.gate_ok == 1)).astype(int)
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict | None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
if len(g) < 15:
|
||||
return None
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean()
|
||||
/ taker_notional(g.r.values, g.c.values).mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
print("\n" + "=" * 92)
|
||||
print("【一】判据:额外信号是不是集中在「末笔未确认」那一组")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.z_sure):
|
||||
rows.append({"中枢末笔": "已确认" if k else "未确认", "信号数": len(g),
|
||||
"额外(全量没有)": int((~g.in_full).sum()),
|
||||
"额外率": f"{(~g.in_full).mean()*100:.1f}%"})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
用户的判断:「没确认的会出现然后消失,确认的不会」。
|
||||
若已确认组额外率接近 0 -> 判断成立,之前 74% 是把两种混在一起统计的结果。""")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【二】亏损是不是也集中在未确认组")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.z_sure):
|
||||
nm = "已确认" if k else "未确认"
|
||||
for lab, x in [("全部", g), ("三道滤网后", g[g.pass_all == 1])]:
|
||||
s = perf(x)
|
||||
if s:
|
||||
rows.append({"中枢末笔": nm, "口径": lab, **s})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【三】若补一道 is_sure 门,实盘口径会变成什么样")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for nm, x in [
|
||||
("现状:三道滤网", d[d.pass_all == 1]),
|
||||
("**加 is_sure 门**", d[(d.pass_all == 1) & d.z_sure]),
|
||||
(" 对照:仅未确认", d[(d.pass_all == 1) & ~d.z_sure]),
|
||||
]:
|
||||
s = perf(x)
|
||||
if s:
|
||||
rows.append({"口径": nm, **s})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
⚠️ `shadow_signal.py` 的三道滤网是同向 + 阶梯 + ATR 门控,**没有 is_sure**。
|
||||
若加上这道门 PF 明显回升,那它就是要补进信号路径的东西。""")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--ltf", default="5m")
|
||||
ap.add_argument("--htf", default="30m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[确认态拆分] {len(syms)} 币 × {args.steps} 根 · {args.ltf}\n",
|
||||
flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.ltf, args.htf, args.rows,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
f"{0 if r is None else len(r)}", flush=True)
|
||||
if r is not None:
|
||||
parts.append(r)
|
||||
if not parts:
|
||||
print("无结果")
|
||||
return
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""逐笔追「额外信号」:全量那一遍到底为什么没产出它。
|
||||
|
||||
用户说「原来的计算是没有问题的,是你算错了」。之前几轮都是统计口径,
|
||||
容易把自己的 bug 说成市场现象。这次不做统计,挑具体信号逐个对账。
|
||||
|
||||
对每个额外信号,把两边的中间量全摆出来:
|
||||
|
||||
回放侧 中枢 (zg,zd)、available_ts、扫描起点、入场根
|
||||
全量侧 同一个中枢是否存在、它的 available_ts、扫描区间 [start, start+200]
|
||||
入场根落不落在这个区间里、`diag` 记的拒绝原因
|
||||
|
||||
四种可能的结论,指向完全不同的处理:
|
||||
|
||||
A 全量里那个中枢的扫描区间**不覆盖**入场根
|
||||
-> available_ts 棘轮,机制成立,不是 bug
|
||||
B 中枢在全量里**不存在**
|
||||
-> 中枢集合本身有差异,要查是不是我窗口用错了
|
||||
C 区间覆盖了、全量却仍没出信号
|
||||
-> 两边输入不同(我传错了 df/zones),**是我的 bug**
|
||||
D 入场根索引对不上(差几根)
|
||||
-> 索引口径错,`chan.dataframe` 与原始 df 不是 1:1,**是我的 bug**
|
||||
|
||||
D 尤其要查:回放里我用原始 df 的下标 i 当 key,全量用的是 `cdf` 的 entry_idx,
|
||||
两者只有在 `TF_DF.dataframe` 与输入逐行对齐时才等价。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
WIN, MAX_GROW, SCAN = 2001, 500, 200
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--sym", default="BTC")
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=3_000)
|
||||
ap.add_argument("--show", type=int, default=8)
|
||||
args = ap.parse_args()
|
||||
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
df = fetch_ohlcv(f"{args.sym}/USDT:USDT", args.tf, args.rows)
|
||||
df = df.iloc[-(WIN + args.steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, args.tf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
|
||||
print("=" * 92)
|
||||
print("【0】先查 D:索引口径是否 1:1")
|
||||
print("=" * 92)
|
||||
print(f"原始 df 行数 {len(df)} · TF_DF.dataframe 行数 {len(cdf)} "
|
||||
f"-> {'一致' if len(df) == len(cdf) else '**不一致,索引口径有问题**'}")
|
||||
if len(df) == len(cdf):
|
||||
t_df = pd.to_datetime(df["date"])
|
||||
t_cd = pd.to_datetime(cdf["date"])
|
||||
if t_df.dt.tz is not None:
|
||||
t_df = t_df.dt.tz_localize(None)
|
||||
if t_cd.dt.tz is not None:
|
||||
t_cd = t_cd.dt.tz_localize(None)
|
||||
same = int((t_df.values == t_cd.values).sum())
|
||||
print(f"逐行时间戳相同 {same}/{len(df)} "
|
||||
f"-> {'逐行对齐' if same == len(df) else '**有错位**'}")
|
||||
|
||||
zsf = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
zf = zones_from_zs_list(zsf, cdf)
|
||||
diag_full: dict = {}
|
||||
sig_full = find_fast_bsp3(cdf, zf, diag=diag_full)
|
||||
fkeys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()}
|
||||
ts = cdf["timestamp"].to_numpy()
|
||||
print(f"\n全量:中枢 {len(zf)} 个,信号 {len(sig_full)} 个")
|
||||
print(f"全量 diag:{diag_full}")
|
||||
|
||||
# ---- 回放 ----
|
||||
rec = []
|
||||
chan, anchor = None, 0
|
||||
for i in range(WIN, len(df)):
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, args.tf)
|
||||
chan.init_stream(w, 1, args.tf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z is None or z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last]
|
||||
if s.empty:
|
||||
continue
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
zi = int(r.zone_i)
|
||||
rec.append({
|
||||
"i": i, "d": int(r.direction),
|
||||
"in_full": (i, int(r.direction)) in fkeys,
|
||||
"zg": float(z.zg.iloc[zi]), "zd": float(z.zd.iloc[zi]),
|
||||
"avail_rt": int(z.available_ts.iloc[zi]),
|
||||
"win_len": len(sub),
|
||||
"bo": int(r.bo_idx), "last": last,
|
||||
})
|
||||
rp = pd.DataFrame(rec)
|
||||
if rp.empty:
|
||||
print("回放无信号")
|
||||
return
|
||||
print(f"\n回放:信号 {len(rp)} 个 · 其中全量也有 "
|
||||
f"{int(rp.in_full.sum())} · 额外 {int((~rp.in_full).sum())}")
|
||||
|
||||
# ---- 逐笔对账 ----
|
||||
print("\n" + "=" * 92)
|
||||
print(f"【1】抽 {args.show} 个额外信号逐笔对账")
|
||||
print("=" * 92)
|
||||
fzg, fzd = zf.zg.to_numpy(float), zf.zd.to_numpy(float)
|
||||
fav = zf.available_ts.to_numpy()
|
||||
n = len(cdf)
|
||||
rows = []
|
||||
for r in rp[~rp.in_full].head(args.show).itertuples():
|
||||
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
|
||||
if not m.any():
|
||||
rows.append({"入场根": r.i, "中枢在全量": "**不存在**",
|
||||
"结论": "B 中枢集合有差异"})
|
||||
continue
|
||||
j = int(np.argmax(m))
|
||||
start = int(np.searchsorted(ts, fav[j], side="left"))
|
||||
end = min(start + SCAN, n)
|
||||
cover = start <= r.i < end
|
||||
rows.append({
|
||||
"入场根": r.i, "中枢在全量": "存在",
|
||||
"回放avail根": int(np.searchsorted(ts, r.avail_rt, side="left")),
|
||||
"全量avail根": start,
|
||||
"棘轮(根)": start - int(np.searchsorted(ts, r.avail_rt,
|
||||
side="left")),
|
||||
"全量扫描区间": f"[{start},{end})",
|
||||
"覆盖入场根": "是" if cover else "否",
|
||||
"结论": "C **是我的bug**" if cover else "A 棘轮,机制成立",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
A = 全量扫描区间不覆盖入场根(available_ts 棘轮后移),机制成立
|
||||
B = 中枢在全量里不存在 -> 中枢集合有差异,要查窗口
|
||||
C = 区间覆盖了全量却没出信号 -> 两边输入不同,是我的 bug""")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【2】全体额外信号按结论归类")
|
||||
print("=" * 92)
|
||||
cnt = {"A 棘轮": 0, "B 中枢不存在": 0, "C 我的bug": 0}
|
||||
for r in rp[~rp.in_full].itertuples():
|
||||
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
|
||||
if not m.any():
|
||||
cnt["B 中枢不存在"] += 1
|
||||
continue
|
||||
j = int(np.argmax(m))
|
||||
start = int(np.searchsorted(ts, fav[j], side="left"))
|
||||
cnt["C 我的bug" if start <= r.i < min(start + SCAN, n)
|
||||
else "A 棘轮"] += 1
|
||||
tot = max(sum(cnt.values()), 1)
|
||||
print(pd.DataFrame([{"结论": k, "个数": v, "占比": f"{v/tot*100:.1f}%"}
|
||||
for k, v in cnt.items()]).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【3】按信号条数统计是否被同一中枢的重复触发放大了")
|
||||
print("=" * 92)
|
||||
print("上表可见 2691/2707/2750 的全量 avail 同为 2767,是**同一个中枢**触发三次。")
|
||||
print("`max_per_zone=1` 只保证「每次扫描返回一个」,但扫描起点随棘轮后移,")
|
||||
print("越过旧入场点后同一中枢会重新产出「第一个」——于是按条数统计被放大。\n")
|
||||
rows = []
|
||||
for nm, x in [("额外", rp[~rp.in_full]), ("全量也有", rp[rp.in_full])]:
|
||||
if x.empty:
|
||||
continue
|
||||
nz = x.groupby(["zg", "zd"]).size()
|
||||
rows.append({"分组": nm, "信号条数": len(x), "不同中枢数": len(nz),
|
||||
"每中枢触发": round(len(x) / len(nz), 2),
|
||||
"最多触发": int(nz.max())})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
若「额外」组每中枢触发 >> 1 而「全量也有」组 ≈ 1,则先前那个「额外信号占 74%」
|
||||
是**按条数**统计的放大结果,不等于实盘会多开 74% 的仓。
|
||||
真实多开多少,取决于执行层对同一中枢是否去重/冷却 —— 那一层仍未审计。""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+21
-9
@@ -108,7 +108,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in analysis_result['bi_list'] if bi.is_sure],
|
||||
# 添加未完成笔列表
|
||||
'uncompleted_bi_list': [{
|
||||
@@ -118,7 +119,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in analysis_result['bi_list'] if not bi.is_sure],
|
||||
'seg_list': [{
|
||||
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
@@ -126,7 +128,9 @@ def analyze():
|
||||
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
|
||||
'direction': convert_direction(seg.dir)
|
||||
'direction': convert_direction(seg.dir),
|
||||
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
|
||||
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
|
||||
} for seg in analysis_result['seg_list'] if seg.is_sure],
|
||||
# 添加未完成线段列表
|
||||
'uncompleted_seg_list': get_uncompleted_seg_list(analysis_result['seg_list'], client_tz),
|
||||
@@ -305,7 +309,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in element_analysis['bi_list'] if bi.is_sure]
|
||||
# 添加次周期未完成笔列表
|
||||
result['element_uncompleted_bi_list'] = [{
|
||||
@@ -315,7 +320,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in element_analysis['bi_list'] if not bi.is_sure]
|
||||
|
||||
# 添加小周期K线数据
|
||||
@@ -341,7 +347,9 @@ def analyze():
|
||||
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
|
||||
'direction': convert_direction(seg.dir)
|
||||
'direction': convert_direction(seg.dir),
|
||||
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
|
||||
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
|
||||
} for seg in element_analysis['seg_list'] if seg.is_sure]
|
||||
# 添加次周期未完成线段列表
|
||||
result['element_uncompleted_seg_list'] = get_uncompleted_seg_list(element_analysis['seg_list'], client_tz)
|
||||
@@ -446,7 +454,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in sub_sub_analysis['bi_list'] if bi.is_sure]
|
||||
result['sub_sub_uncompleted_bi_list'] = [{
|
||||
'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
@@ -455,7 +464,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in sub_sub_analysis['bi_list'] if not bi.is_sure]
|
||||
# 次次周期 KLC 列表
|
||||
result['sub_sub_klc_list'] = [{
|
||||
@@ -477,7 +487,9 @@ def analyze():
|
||||
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
|
||||
'direction': convert_direction(seg.dir)
|
||||
'direction': convert_direction(seg.dir),
|
||||
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
|
||||
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
|
||||
} for seg in sub_sub_analysis['seg_list'] if seg.is_sure]
|
||||
result['sub_sub_uncompleted_seg_list'] = get_uncompleted_seg_list(sub_sub_analysis['seg_list'], client_tz)
|
||||
result['sub_sub_zs_list'] = [{
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""把 data_provider 的资金面转给 Web,浏览器不直连交易所。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from services.runtime.market_data import (
|
||||
fetch_derivatives,
|
||||
fetch_sentiment_latest,
|
||||
fetch_sentiment_metrics,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("provider", __name__)
|
||||
|
||||
|
||||
def _http_error(exc: requests.HTTPError):
|
||||
status = 502
|
||||
detail = str(exc)
|
||||
if exc.response is not None:
|
||||
status = exc.response.status_code or 502
|
||||
try:
|
||||
body = exc.response.json()
|
||||
detail = body.get("detail") or body.get("error") or detail
|
||||
except Exception:
|
||||
detail = exc.response.text or detail
|
||||
return jsonify({"error": detail}), status
|
||||
|
||||
|
||||
@bp.route("/api/derivatives")
|
||||
def api_derivatives():
|
||||
symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip()
|
||||
exchange = (request.args.get("exchange") or "").strip() or None
|
||||
try:
|
||||
return jsonify(fetch_derivatives(symbol, exchange))
|
||||
except requests.HTTPError as exc:
|
||||
return _http_error(exc)
|
||||
except Exception as exc:
|
||||
logger.warning("derivatives 中转失败: %s", exc)
|
||||
return jsonify({"error": str(exc)}), 503
|
||||
|
||||
|
||||
@bp.route("/api/sentiment/latest")
|
||||
def api_sentiment_latest():
|
||||
symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip()
|
||||
try:
|
||||
return jsonify(fetch_sentiment_latest(symbol))
|
||||
except requests.HTTPError as exc:
|
||||
return _http_error(exc)
|
||||
except Exception as exc:
|
||||
logger.warning("sentiment latest 中转失败: %s", exc)
|
||||
return jsonify({"error": str(exc)}), 503
|
||||
|
||||
|
||||
@bp.route("/api/sentiment/metrics")
|
||||
def api_sentiment_metrics():
|
||||
metric = (request.args.get("metric") or "").strip()
|
||||
if not metric:
|
||||
return jsonify({"error": "metric required"}), 400
|
||||
symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip()
|
||||
start = request.args.get("start", type=int)
|
||||
end = request.args.get("end", type=int)
|
||||
limit = request.args.get("limit", type=int)
|
||||
try:
|
||||
return jsonify(fetch_sentiment_metrics(metric, symbol, start=start, end=end, limit=limit))
|
||||
except requests.HTTPError as exc:
|
||||
return _http_error(exc)
|
||||
except Exception as exc:
|
||||
logger.warning("sentiment 中转失败: %s", exc)
|
||||
return jsonify({"error": str(exc)}), 503
|
||||
@@ -13,6 +13,7 @@ from flask import Flask
|
||||
from config import FLASK_HOST, FLASK_PORT
|
||||
from api.analyze import bp as analyze_bp
|
||||
from api.pages import bp as pages_bp
|
||||
from api.provider import bp as provider_bp
|
||||
from api.symbols import bp as symbols_bp
|
||||
from api.trend import bp as trend_bp
|
||||
|
||||
@@ -23,6 +24,7 @@ def create_app() -> Flask:
|
||||
app.register_blueprint(analyze_bp)
|
||||
app.register_blueprint(symbols_bp)
|
||||
app.register_blueprint(trend_bp)
|
||||
app.register_blueprint(provider_bp)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ from .market_data import ( # noqa: F401
|
||||
get_crypto_kl_data,
|
||||
get_a_stock_kl_data,
|
||||
load_crypto_symbols,
|
||||
fetch_derivatives,
|
||||
fetch_sentiment_metrics,
|
||||
fetch_sentiment_latest,
|
||||
)
|
||||
from .indicators import ( # noqa: F401
|
||||
add_indicators,
|
||||
|
||||
@@ -48,6 +48,10 @@ def analyze_chan(df, symbol=None, timeframe=None):
|
||||
for bi in bi_list:
|
||||
bi.cal_macd_div()
|
||||
#print(bi.start_time, bi.macd_hist, bi.macd_div)
|
||||
for seg in seg_list:
|
||||
seg.cal_macdhist()
|
||||
for seg in seg_list:
|
||||
seg.cal_macd_div()
|
||||
|
||||
# 添加ChanMACD分析(复用 get_klc_list 内已算好的结果,避免同周期二次全量分析)
|
||||
chan_macd = None
|
||||
|
||||
@@ -319,3 +319,34 @@ def load_crypto_symbols(limit=200):
|
||||
except Exception:
|
||||
return DEFAULT_SYMBOLS[:limit]
|
||||
|
||||
|
||||
def _provider_get(path, params, timeout=8):
|
||||
resp = requests.get(f"{DATA_SERVICE_URL}{path}", params=params, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def fetch_derivatives(symbol, exchange=None):
|
||||
"""当前资金面快照。只打 data_provider,不打交易所。"""
|
||||
params = {"symbol": symbol}
|
||||
if exchange:
|
||||
params["exchange"] = exchange
|
||||
return _provider_get("/api/derivatives", params)
|
||||
|
||||
|
||||
def fetch_sentiment_metrics(metric, symbol, start=None, end=None, limit=None):
|
||||
"""情绪/资金面序列。只打 data_provider。"""
|
||||
params = {"metric": metric, "symbol": symbol}
|
||||
if start is not None:
|
||||
params["start"] = int(start)
|
||||
if end is not None:
|
||||
params["end"] = int(end)
|
||||
if limit is not None:
|
||||
params["limit"] = int(limit)
|
||||
return _provider_get("/api/sentiment/metrics", params)
|
||||
|
||||
|
||||
def fetch_sentiment_latest(symbol):
|
||||
"""情绪面最新快照。只打 data_provider。"""
|
||||
return _provider_get("/api/sentiment/latest", {"symbol": symbol})
|
||||
|
||||
|
||||
@@ -98,8 +98,14 @@ def serialize_chan_macd_data(chan_macd_data, client_tz):
|
||||
# 序列化unittf_list(兼容新结构与枚举类型)
|
||||
for unittf in chan_macd_data.get('unittf_list', []):
|
||||
try:
|
||||
dir_value = getattr(unittf, 'uinttf_dir', None)
|
||||
dir_value = getattr(unittf, 'unittf_dir', None)
|
||||
dir_name = getattr(dir_value, 'name', dir_value if isinstance(dir_value, str) else None)
|
||||
if dir_name == 'ABOVE':
|
||||
dir_num = 1
|
||||
elif dir_name == 'UNDER':
|
||||
dir_num = -1
|
||||
else:
|
||||
dir_num = 0
|
||||
start_t = getattr(unittf, 'start_type', None)
|
||||
start_type = getattr(start_t, 'name', start_t)
|
||||
end_t = getattr(unittf, 'end_type', None)
|
||||
@@ -114,7 +120,7 @@ def serialize_chan_macd_data(chan_macd_data, client_tz):
|
||||
unittf_data = {
|
||||
'start_time': format_time_safely(getattr(unittf, 'start_time', None), client_tz),
|
||||
'end_time': format_time_safely(getattr(unittf, 'end_time', None), client_tz) if getattr(unittf, 'end_time', None) else None,
|
||||
'dir': dir_name, # 'ABOVE' | 'UNDER' | None
|
||||
'dir': dir_num,
|
||||
'start_type': start_type, # e.g. 'START' | 'CROSS0' | 'NEAR0_UP' | 'NEAR0_DOWN'
|
||||
'end_type': end_type,
|
||||
'invalid': getattr(unittf, 'invalid', False),
|
||||
@@ -307,7 +313,9 @@ def get_uncompleted_seg_list(seg_list, client_tz):
|
||||
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||
'direction': convert_direction(seg.dir)
|
||||
'direction': convert_direction(seg.dir),
|
||||
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
|
||||
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
|
||||
}
|
||||
|
||||
if is_last:
|
||||
|
||||
@@ -46,6 +46,7 @@ DEFAULT_TIMEFRAME_LABELS = OrderedDict([
|
||||
("5m", "5分钟"),
|
||||
("15m", "15分钟"),
|
||||
("30m", "30分钟"),
|
||||
("45m", "45分钟"),
|
||||
("1h", "1小时"),
|
||||
("2h", "2小时"),
|
||||
("4h", "4小时"),
|
||||
|
||||
@@ -94,19 +94,19 @@ def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys):
|
||||
def compute_timeframe_defaults(labels_ordered):
|
||||
"""
|
||||
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
|
||||
默认偏好:主 4h、次 1h、次次 15m。
|
||||
默认偏好:主 45m、次 15m、次次 5m。
|
||||
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
|
||||
"""
|
||||
if not labels_ordered:
|
||||
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
|
||||
timeframe_keys = list(labels_ordered.keys())
|
||||
preferred_main = next((tf for tf in ['4h', '1h', '15m'] if tf in labels_ordered), None)
|
||||
preferred_main = next((tf for tf in ['45m', '30m', '1h'] if tf in labels_ordered), None)
|
||||
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
|
||||
if default_main not in labels_ordered and timeframe_keys:
|
||||
default_main = timeframe_keys[0]
|
||||
|
||||
default_element = _prefer_smaller(['1h', '15m'], labels_ordered, default_main, timeframe_keys)
|
||||
default_sub_sub = _prefer_smaller(['15m', '5m'], labels_ordered, default_element, timeframe_keys)
|
||||
default_element = _prefer_smaller(['15m', '5m', '30m'], labels_ordered, default_main, timeframe_keys)
|
||||
default_sub_sub = _prefer_smaller(['5m', '1m', '15m'], labels_ordered, default_element, timeframe_keys)
|
||||
|
||||
return default_main, default_element, default_sub_sub, timeframe_keys
|
||||
|
||||
|
||||
@@ -10,6 +10,33 @@ window.ChanApi = {
|
||||
symbols: function() {
|
||||
return fetch('/api/symbols').then(r => r.json());
|
||||
},
|
||||
derivatives: function(params) {
|
||||
const q = new URLSearchParams(params || {});
|
||||
return fetch('/api/derivatives?' + q.toString()).then(function(r) {
|
||||
return r.json().then(function(body) {
|
||||
if (!r.ok) throw new Error((body && body.error) || r.statusText);
|
||||
return body;
|
||||
});
|
||||
});
|
||||
},
|
||||
sentimentLatest: function(params) {
|
||||
const q = new URLSearchParams(params || {});
|
||||
return fetch('/api/sentiment/latest?' + q.toString()).then(function(r) {
|
||||
return r.json().then(function(body) {
|
||||
if (!r.ok) throw new Error((body && body.error) || r.statusText);
|
||||
return body;
|
||||
});
|
||||
});
|
||||
},
|
||||
sentimentMetrics: function(params) {
|
||||
const q = new URLSearchParams(params || {});
|
||||
return fetch('/api/sentiment/metrics?' + q.toString()).then(function(r) {
|
||||
return r.json().then(function(body) {
|
||||
if (!r.ok) throw new Error((body && body.error) || r.statusText);
|
||||
return body;
|
||||
});
|
||||
});
|
||||
},
|
||||
macdConfig: function(body) {
|
||||
if (body === undefined) return fetch('/api/macd_config').then(r => r.json());
|
||||
return fetch('/api/macd_config', {
|
||||
|
||||
@@ -229,29 +229,29 @@ function updateTradingViewData(options) {
|
||||
}
|
||||
|
||||
// 更新MACD数据
|
||||
if (tvWidget.series.macdLineSeries && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) {
|
||||
// 提取MACD数据
|
||||
const macdKlineSrc = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? (currentData.element_kline_data || []) : (currentData.kline_data || []));
|
||||
const macdSrc = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
|
||||
if (tvWidget.series.macdLineSeries && macdSrc && macdKlineSrc && Array.isArray(macdKlineSrc)) {
|
||||
const macdData = [];
|
||||
const signalData = [];
|
||||
const histogramData = [];
|
||||
|
||||
for (let i = 0; i < currentData.kline_data.length; i++) {
|
||||
const kline = currentData.kline_data[i];
|
||||
for (let i = 0; i < macdKlineSrc.length; i++) {
|
||||
const kline = macdKlineSrc[i];
|
||||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||||
|
||||
if (currentData.macd && currentData.macd.macd && currentData.macd.macd[i] !== undefined) {
|
||||
if (macdSrc && macdSrc.macd && macdSrc.macd[i] !== undefined) {
|
||||
macdData.push({
|
||||
time: timestamp,
|
||||
value: currentData.macd.macd[i]
|
||||
value: macdSrc.macd[i]
|
||||
});
|
||||
|
||||
signalData.push({
|
||||
time: timestamp,
|
||||
value: currentData.macd.signal[i]
|
||||
value: macdSrc.signal[i]
|
||||
});
|
||||
|
||||
// 设置直方图颜色
|
||||
const histValue = currentData.macd.histogram[i];
|
||||
const histValue = macdSrc.histogram[i];
|
||||
histogramData.push({
|
||||
time: timestamp,
|
||||
value: histValue,
|
||||
@@ -309,6 +309,9 @@ function updateTradingViewData(options) {
|
||||
}
|
||||
);
|
||||
}
|
||||
if (typeof refreshUnittfOverlayFromData === 'function') {
|
||||
refreshUnittfOverlayFromData(currentData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('更新ChanMACD标注失败:', e);
|
||||
}
|
||||
@@ -331,7 +334,8 @@ function updateTradingViewData(options) {
|
||||
tvWidget.volumeChart,
|
||||
tvWidget.atrChart,
|
||||
tvWidget.macdChart,
|
||||
tvWidget.chanMacdChart
|
||||
tvWidget.chanMacdChart,
|
||||
tvWidget.sentimentChart
|
||||
].filter(Boolean);
|
||||
|
||||
const vr = clampedVisibleRange || savedVisibleRange;
|
||||
@@ -409,8 +413,11 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
volume: false,
|
||||
atr: false,
|
||||
macd: false,
|
||||
chanmacd: false
|
||||
chanmacd: false,
|
||||
sentiment: false
|
||||
};
|
||||
const sentimentChart = tvWidget && tvWidget.sentimentChart;
|
||||
const sentimentChartContainer = tvWidget && tvWidget.sentimentChartContainer;
|
||||
|
||||
// 同步图表的时间范围
|
||||
function syncCharts(sourceChart, sourceContainer) {
|
||||
@@ -438,6 +445,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) {
|
||||
try { chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||||
}
|
||||
if (sentimentChart && sourceChart !== sentimentChart && sentimentChart.timeScale) {
|
||||
try { sentimentChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||||
}
|
||||
|
||||
if (tvWidget && tvWidget.state) {
|
||||
tvWidget.state.logicalRange = logicalRange;
|
||||
@@ -458,7 +468,8 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
chart === volumeChart ? 'volume' :
|
||||
chart === atrChart ? 'atr' :
|
||||
chart === macdChart ? 'macd' :
|
||||
chart === chanMacdChart ? 'chanmacd' : 'unknown';
|
||||
chart === chanMacdChart ? 'chanmacd' :
|
||||
chart === sentimentChart ? 'sentiment' : 'unknown';
|
||||
|
||||
const timeRangeHandler = () => {
|
||||
if (!syncInProgress) {
|
||||
@@ -515,6 +526,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
if (showMacd && chanMacdChartContainer && chanMacdChart) {
|
||||
addChartSyncEvents(chanMacdChartContainer, chanMacdChart);
|
||||
}
|
||||
if (sentimentChartContainer && sentimentChart) {
|
||||
addChartSyncEvents(sentimentChartContainer, sentimentChart);
|
||||
}
|
||||
|
||||
// 窗口大小变化时重绘图表 — 使用可清理的方式注册
|
||||
const resizeHandler = () => {
|
||||
@@ -533,6 +547,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
if (showMacd && chanMacdChart && chanMacdChartContainer) {
|
||||
chanMacdChart.applyOptions({ width: chanMacdChartContainer.clientWidth, height: chanMacdChartContainer.clientHeight });
|
||||
}
|
||||
if (sentimentChart && sentimentChartContainer) {
|
||||
sentimentChart.applyOptions({ width: sentimentChartContainer.clientWidth, height: sentimentChartContainer.clientHeight });
|
||||
}
|
||||
setTimeout(() => { if (mainChart) syncCharts(mainChart, mainChartContainer); }, 200);
|
||||
};
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
@@ -549,9 +566,11 @@ function setupTooltip(mainChart, buyMarkers = [], sellMarkers = [], mainChartCon
|
||||
// 初始化 U 显示状态(主/次周期分开控制)
|
||||
const isShowUMain = $('#toggleUOnMain').is(':checked');
|
||||
const isShowUElement = $('#toggleUOnElement').is(':checked');
|
||||
const isShowUSubSub = $('#toggleUOnSubSub').is(':checked');
|
||||
window.showUOnMain = isShowUMain;
|
||||
window.showUOnElement = isShowUElement;
|
||||
if (!isShowUMain && !isShowUElement) {
|
||||
window.showUOnSubSub = isShowUSubSub;
|
||||
if (!isShowUMain && !isShowUElement && !isShowUSubSub) {
|
||||
// 隐藏时清空子图上的 U 标记
|
||||
if (tvWidget.series && tvWidget.series.chanMacdLineSeries) {
|
||||
try { tvWidget.series.chanMacdLineSeries.setMarkers([]); } catch (e) {}
|
||||
|
||||
@@ -169,7 +169,7 @@ function updateTables(currentData) {
|
||||
});
|
||||
|
||||
// 更新数据源信息显示
|
||||
const selectedPeriod = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期');
|
||||
const selectedPeriod = useSubSubPeriod ? '次次' : (useElementPeriod ? '次' : '主');
|
||||
const timeframe = useSubSubPeriod && data.sub_sub_timeframe ? data.sub_sub_timeframe : (useElementPeriod && data.element_timeframe ? data.element_timeframe : $('#timeframe').val());
|
||||
$('#dataSourceText').html(`当前显示的是<strong>${selectedPeriod} (${timeframe})</strong> 数据`);
|
||||
|
||||
@@ -297,55 +297,55 @@ function setupDataSourceInfo(data) {
|
||||
$('#kline-tab, #macd-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_kline_data && data.sub_sub_kline_data.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 数据`);
|
||||
} else if (useElementPeriod && data.element_kline_data && data.element_kline_data.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次 (${elementTimeframe})</strong> 数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#bi-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_bi_list && data.sub_sub_bi_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 笔数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 笔数据`);
|
||||
} else if (useElementPeriod && data.element_bi_list && data.element_bi_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 笔数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次 (${elementTimeframe})</strong> 笔数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 笔数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 笔数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#seg-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_seg_list && data.sub_sub_seg_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 线段数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 线段数据`);
|
||||
} else if (useElementPeriod && data.element_seg_list && data.element_seg_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 线段数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次 (${elementTimeframe})</strong> 线段数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 线段数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 线段数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#zs-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_zs_list && data.sub_sub_zs_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 中枢数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 中枢数据`);
|
||||
} else if (useElementPeriod && data.element_zs_list && data.element_zs_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 中枢数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次 (${elementTimeframe})</strong> 中枢数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 中枢数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 中枢数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#trade-points-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_bsp_list && data.sub_sub_bsp_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 买卖点数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 买卖点数据`);
|
||||
} else if (useElementPeriod && data.element_trade_points && data.element_trade_points.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 买卖点数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次 (${elementTimeframe})</strong> 买卖点数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 买卖点数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 买卖点数据`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ function initTradingView(symbol, timeframe) {
|
||||
chartTvRenderChan(ctx);
|
||||
chartTvRenderOverlays(ctx);
|
||||
chartTvFinalize(ctx);
|
||||
if (window.ChanDeriv) ChanDeriv.loadOverlays();
|
||||
|
||||
console.log('图表初始化完成');
|
||||
} catch (e) {
|
||||
|
||||
@@ -178,38 +178,6 @@ function chartTvRenderChan(ctx) {
|
||||
color: bi.direction === 1 ? '#dc3545' : '#28a745',
|
||||
lineWidth: 1
|
||||
});
|
||||
|
||||
// 在笔的末端添加macd_div值标记
|
||||
if (bi.macd_div && bi.macd_div !== 0 && $('#showMainMacdDiv').is(':checked')) {
|
||||
console.log(`添加主周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`);
|
||||
|
||||
const macdDivLabel = mainChart.addLineSeries({
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
color: 'transparent', // 设置为透明色
|
||||
lineWidth: 0, // 线宽为0
|
||||
});
|
||||
|
||||
// 添加一个透明的数据点用于承载标记
|
||||
macdDivLabel.setData([
|
||||
{ time: endTime, value: endPrice }
|
||||
]);
|
||||
|
||||
// 主周期MACD背离标记根据笔方向显示,远离K线避免与分型重叠
|
||||
const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar';
|
||||
const textColor = bi.macd_div > 0 ? '#dc3545' : '#28a745';
|
||||
|
||||
// 只使用标记,不添加数据点
|
||||
macdDivLabel.setMarkers([
|
||||
{
|
||||
time: endTime,
|
||||
position: markerPosition,
|
||||
color: textColor,
|
||||
text: `${bi.macd_div.toFixed(2)}`, // 添加M前缀区分
|
||||
size: 0.6, // 更小的尺寸,远离分型标记
|
||||
}
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('主周期笔处理出错:', e);
|
||||
}
|
||||
@@ -260,38 +228,6 @@ function chartTvRenderChan(ctx) {
|
||||
color: bi.direction === 1 ? '#9c27b0' : '#673ab7',
|
||||
lineWidth: 1
|
||||
});
|
||||
|
||||
// 在笔的末端添加macd_div值标记
|
||||
if (bi.macd_div && bi.macd_div !== 0 && $('#showElementMacdDiv').is(':checked')) {
|
||||
console.log(`添加元素周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`);
|
||||
|
||||
const macdDivLabel = mainChart.addLineSeries({
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
color: 'transparent', // 设置为透明色
|
||||
lineWidth: 0, // 线宽为0
|
||||
});
|
||||
|
||||
// 添加一个透明的数据点用于承载标记
|
||||
macdDivLabel.setData([
|
||||
{ time: endTime, value: endPrice }
|
||||
]);
|
||||
|
||||
// 次周期MACD背离标记使用不同位置,进一步避免重叠
|
||||
const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar';
|
||||
const textColor = bi.macd_div > 0 ? '#9c27b0' : '#673ab7';
|
||||
|
||||
// 只使用标记,不添加数据点
|
||||
macdDivLabel.setMarkers([
|
||||
{
|
||||
time: endTime,
|
||||
position: markerPosition,
|
||||
color: textColor,
|
||||
text: `${bi.macd_div.toFixed(2)}`, // 添加E前缀区分次周期
|
||||
size: 0.4, // 更小的尺寸,让分型标记有更多空间
|
||||
}
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('次周期笔处理出错:', e);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ function chartTvFinalize(ctx) {
|
||||
var atrChart = ctx.atrChart;
|
||||
var macdChart = ctx.macdChart;
|
||||
var chanMacdChart = ctx.chanMacdChart;
|
||||
var sentimentChart = ctx.sentimentChart;
|
||||
var sentimentChartContainer = ctx.sentimentChartContainer;
|
||||
var createChartOptions = ctx.createChartOptions;
|
||||
// 同步所有图表的时间轴配置
|
||||
const hasPendingRestoreView = !!window._pendingRestoreView;
|
||||
@@ -55,6 +57,9 @@ function chartTvFinalize(ctx) {
|
||||
if (showMacd && macdChart) {
|
||||
macdChart.timeScale().applyOptions(baseOptions);
|
||||
}
|
||||
if (sentimentChart) {
|
||||
sentimentChart.timeScale().applyOptions(baseOptions);
|
||||
}
|
||||
};
|
||||
|
||||
// 首先同步时间轴设置
|
||||
@@ -65,7 +70,8 @@ function chartTvFinalize(ctx) {
|
||||
const visibleBarsCount = 200;
|
||||
const allChartsNow = [mainChart, volumeChart, atrChart]
|
||||
.concat(showMacd && macdChart ? [macdChart] : [])
|
||||
.concat(showMacd && chanMacdChart ? [chanMacdChart] : []);
|
||||
.concat(showMacd && chanMacdChart ? [chanMacdChart] : [])
|
||||
.concat(sentimentChart ? [sentimentChart] : []);
|
||||
const restoreOpts = function () {
|
||||
const firstT = candles && candles.length ? candles[0].time : null;
|
||||
const lastT = candles && candles.length ? candles[candles.length - 1].time : null;
|
||||
@@ -103,6 +109,9 @@ function chartTvFinalize(ctx) {
|
||||
if (showMacd && chanMacdChart) {
|
||||
chanMacdChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
if (sentimentChart) {
|
||||
sentimentChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -117,6 +126,9 @@ function chartTvFinalize(ctx) {
|
||||
if (showMacd && chanMacdChart) {
|
||||
chanMacdChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
if (sentimentChart) {
|
||||
sentimentChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
console.log('🔧 时间轴同步完成');
|
||||
}
|
||||
}, 50);
|
||||
@@ -127,6 +139,8 @@ function chartTvFinalize(ctx) {
|
||||
tvWidget.atrChart = atrChart;
|
||||
tvWidget.macdChart = macdChart;
|
||||
tvWidget.chanMacdChart = chanMacdChart;
|
||||
tvWidget.sentimentChart = sentimentChart;
|
||||
tvWidget.sentimentChartContainer = sentimentChartContainer;
|
||||
tvWidget.state.isInitialized = true;
|
||||
// 注册窗口卸载时释放资源,避免GPU内存泄漏
|
||||
window.onbeforeunload = function() {
|
||||
@@ -137,6 +151,7 @@ function chartTvFinalize(ctx) {
|
||||
if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') tvWidget.macdChart.remove();
|
||||
if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') tvWidget.chanMacdChart.remove();
|
||||
if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') tvWidget.atrChart.remove();
|
||||
if (tvWidget.sentimentChart && typeof tvWidget.sentimentChart.remove === 'function') tvWidget.sentimentChart.remove();
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
@@ -220,6 +235,7 @@ function chartTvFinalize(ctx) {
|
||||
const allCharts = [mainChart, volumeChart, atrChart];
|
||||
if (showMacd && macdChart) allCharts.push(macdChart);
|
||||
if (showMacd && chanMacdChart) allCharts.push(chanMacdChart);
|
||||
if (sentimentChart) allCharts.push(sentimentChart);
|
||||
|
||||
// 检查是否有待恢复的视图(缩放 + 位置)
|
||||
const pending = window._pendingRestoreView || pendingView;
|
||||
@@ -243,7 +259,8 @@ function chartTvFinalize(ctx) {
|
||||
console.log('🔧 最终同步可见范围:', visibleRange);
|
||||
[volumeChart, atrChart].concat(
|
||||
showMacd && macdChart ? [macdChart] : [],
|
||||
showMacd && chanMacdChart ? [chanMacdChart] : []
|
||||
showMacd && chanMacdChart ? [chanMacdChart] : [],
|
||||
sentimentChart ? [sentimentChart] : []
|
||||
).forEach(c => {
|
||||
try { c.timeScale().setVisibleRange(visibleRange); } catch(e) {}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
/* chart_tv_indicators.js — volume / ATR / ChanMACD */
|
||||
|
||||
function formatSdMarkerText(separateDiv) {
|
||||
const n = Number(separateDiv);
|
||||
return `SD${n === 99999 ? 0 : n}`;
|
||||
}
|
||||
|
||||
function shouldShowSdMarker(separateDiv) {
|
||||
const n = Number(separateDiv);
|
||||
return isFinite(n) && n > 0 && n !== 99999;
|
||||
}
|
||||
|
||||
var SD_CD_MARKER_COLOR = '#dc3545';
|
||||
var SD_CD_MARKER_SIZE = 1.2;
|
||||
|
||||
function histArrowShape(pos) {
|
||||
return pos === 'belowBar' ? 'arrowDown' : 'arrowUp';
|
||||
}
|
||||
|
||||
function makeSdMarker(ts, pos, separateDiv) {
|
||||
return {
|
||||
time: ts,
|
||||
position: pos,
|
||||
color: SD_CD_MARKER_COLOR,
|
||||
shape: histArrowShape(pos),
|
||||
text: formatSdMarkerText(separateDiv),
|
||||
size: SD_CD_MARKER_SIZE
|
||||
};
|
||||
}
|
||||
|
||||
function makeCdMarker(ts, pos) {
|
||||
return {
|
||||
time: ts,
|
||||
position: pos,
|
||||
color: SD_CD_MARKER_COLOR,
|
||||
shape: histArrowShape(pos),
|
||||
text: 'CD',
|
||||
size: SD_CD_MARKER_SIZE
|
||||
};
|
||||
}
|
||||
|
||||
function chartTvRenderIndicators(ctx) {
|
||||
var symbol = ctx.symbol;
|
||||
var timeframe = ctx.timeframe;
|
||||
@@ -141,11 +180,11 @@ function chartTvRenderIndicators(ctx) {
|
||||
|
||||
// 使用与K线数据相同的数据源来确保时间对齐
|
||||
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
|
||||
const macdDataSource = useElementPeriod ?
|
||||
(currentData.element_macd || currentData.macd) : // 如果有次周期MACD数据则使用,否则使用主周期
|
||||
currentData.macd; // 主周期使用主周期MACD数据
|
||||
const macdDataSource = useSubSubPeriod
|
||||
? (currentData.sub_sub_macd || currentData.macd)
|
||||
: (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
|
||||
|
||||
console.log('MACD数据源选择:', useElementPeriod ? '次周期' : '主周期');
|
||||
console.log('MACD数据源选择:', useSubSubPeriod ? '次次周期' : (useElementPeriod ? '次周期' : '主周期'));
|
||||
console.log('K线数据长度:', klineDataSource.length);
|
||||
console.log('MACD数据:', macdDataSource);
|
||||
|
||||
@@ -209,7 +248,7 @@ function chartTvRenderIndicators(ctx) {
|
||||
const chanMacdLineSeries = chanMacdChart.addLineSeries({
|
||||
color: '#2962FF',
|
||||
lineWidth: 1,
|
||||
title: 'ChanMACD',
|
||||
title: 'MACD',
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
});
|
||||
@@ -370,7 +409,8 @@ function chartTvRenderIndicators(ctx) {
|
||||
const k = klineArr[i];
|
||||
if (!k || !k.date) continue;
|
||||
const t = Math.floor(new Date(k.date).getTime() / 1000);
|
||||
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
|
||||
const hist = (macdObj.histogram && macdObj.histogram[i] !== undefined && macdObj.histogram[i] !== null) ? macdObj.histogram[i] : null;
|
||||
const val = (hist !== null) ? hist : ((macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null);
|
||||
map.set(t, val);
|
||||
}
|
||||
return map;
|
||||
@@ -387,15 +427,15 @@ function chartTvRenderIndicators(ctx) {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = mainMacdMap.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
mainMarkers.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
mainMarkers.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = mainMacdMap.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
mainMarkers.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
mainMarkers.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
mainMarkers.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -409,15 +449,15 @@ function chartTvRenderIndicators(ctx) {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = elementMacdMap.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
elementMarkers.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
elementMarkers.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = elementMacdMap.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
elementMarkers.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
elementMarkers.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
elementMarkers.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -427,21 +467,24 @@ function chartTvRenderIndicators(ctx) {
|
||||
|
||||
const subSubCm = currentData.sub_sub_chan_macd || {};
|
||||
const subSubMarkers = [];
|
||||
const subSubMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data);
|
||||
const subSubMacdMap = buildMacdTimeMap(
|
||||
(currentData.sub_sub_macd || currentData.macd),
|
||||
(currentData.sub_sub_kline_data || currentData.kline_data)
|
||||
);
|
||||
if (window.showUOnSubSub && Array.isArray(subSubCm.klu_list)) {
|
||||
subSubCm.klu_list.forEach((item) => {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = subSubMacdMap.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
subSubMarkers.push({ time: ts, position: posSd, color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
subSubMarkers.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = subSubMacdMap.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
subSubMarkers.push({ time: ts, position: posCd, color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
subSubMarkers.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
subSubMarkers.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -484,7 +527,8 @@ function chartTvRenderIndicators(ctx) {
|
||||
const k = klineArr[i];
|
||||
if (!k || !k.date) continue;
|
||||
const t = Math.floor(new Date(k.date).getTime() / 1000);
|
||||
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
|
||||
const hist = (macdObj.histogram && macdObj.histogram[i] !== undefined && macdObj.histogram[i] !== null) ? macdObj.histogram[i] : null;
|
||||
const val = (hist !== null) ? hist : ((macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null);
|
||||
map.set(t, val);
|
||||
}
|
||||
return map;
|
||||
@@ -494,20 +538,24 @@ function chartTvRenderIndicators(ctx) {
|
||||
(currentData.element_macd || currentData.macd),
|
||||
(currentData.element_kline_data || currentData.kline_data)
|
||||
);
|
||||
const subSubMacdMapAll = buildMacdTimeMapAll(
|
||||
(currentData.sub_sub_macd || currentData.macd),
|
||||
(currentData.sub_sub_kline_data || currentData.kline_data)
|
||||
);
|
||||
if ((typeof window.showUOnMain === 'undefined' ? false : window.showUOnMain) && Array.isArray(mainCmAll.klu_list)) {
|
||||
mainCmAll.klu_list.forEach((item) => {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = mainMacdMapAll.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
mainMarkersAll.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
mainMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = mainMacdMapAll.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
mainMarkersAll.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
mainMarkersAll.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
mainMarkersAll.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -519,15 +567,15 @@ function chartTvRenderIndicators(ctx) {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = elementMacdMapAll.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
elementMarkersAll.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
elementMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = elementMacdMapAll.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
elementMarkersAll.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
elementMarkersAll.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
elementMarkersAll.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -543,11 +591,15 @@ function chartTvRenderIndicators(ctx) {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
subSubMarkersAll.push({ time: ts, position: 'aboveBar', color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = subSubMacdMapAll.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
subSubMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
const macdVal = subSubMacdMapAll.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
subSubMarkersAll.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -555,6 +607,9 @@ function chartTvRenderIndicators(ctx) {
|
||||
});
|
||||
}
|
||||
window.kluDivMarkersSubSub = subSubMarkersAll;
|
||||
if (typeof refreshUnittfOverlayFromData === 'function') {
|
||||
refreshUnittfOverlayFromData(currentData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('独立计算 KLU 背驰标记出错:', e);
|
||||
window.kluDivMarkersMain = [];
|
||||
|
||||
@@ -25,7 +25,7 @@ function disposeTradingViewCharts() {
|
||||
}
|
||||
|
||||
if (tvWidget) {
|
||||
['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart'].forEach(function (key) {
|
||||
['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart', 'sentimentChart'].forEach(function (key) {
|
||||
try {
|
||||
if (tvWidget[key] && typeof tvWidget[key].remove === 'function') {
|
||||
tvWidget[key].remove();
|
||||
|
||||
@@ -82,6 +82,97 @@ var SUB_SUB_KLC_TREND_STYLE = {
|
||||
UNKNOWN: { position: 'inBar', color: '#004d40', shape: 'square', size: 0.5 }
|
||||
};
|
||||
|
||||
function pushAreaTextLabel(out, item, style, text) {
|
||||
if (!item || !item.end_time || !text) return;
|
||||
var ts = Math.floor(new Date(item.end_time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
var price = Number(item.end_price);
|
||||
if (!isFinite(price)) price = Number(item.start_price);
|
||||
if (!isFinite(price)) return;
|
||||
var up = Number(item.direction) === 1;
|
||||
out.push({
|
||||
time: ts,
|
||||
price: price,
|
||||
text: text,
|
||||
color: style.color,
|
||||
above: up
|
||||
});
|
||||
}
|
||||
|
||||
function formatMacdAreaText(v) {
|
||||
var n = Number(v);
|
||||
if (!isFinite(n) || n === 0) return '';
|
||||
var abs = Math.abs(n);
|
||||
if (abs >= 100) return n.toFixed(0);
|
||||
if (abs >= 10) return n.toFixed(1);
|
||||
return n.toFixed(2);
|
||||
}
|
||||
|
||||
function pushAreaHistMarker(out, item, style) {
|
||||
pushAreaTextLabel(out, item, style, formatMacdAreaText(item && item.macd_hist));
|
||||
}
|
||||
|
||||
function collectAreaHistMarkers(biList, uncompletedBi, segList, uncompletedSeg, biStyle, segStyle, showBi, showSeg) {
|
||||
var out = [];
|
||||
if (showBi) {
|
||||
(biList || []).forEach(function (bi) { pushAreaHistMarker(out, bi, biStyle); });
|
||||
(uncompletedBi || []).forEach(function (bi) { pushAreaHistMarker(out, bi, biStyle); });
|
||||
}
|
||||
if (showSeg) {
|
||||
(segList || []).forEach(function (seg) { pushAreaHistMarker(out, seg, segStyle); });
|
||||
(uncompletedSeg || []).forEach(function (seg) { pushAreaHistMarker(out, seg, segStyle); });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildAreaHistMarkersFromData(data) {
|
||||
var markers = [];
|
||||
if (!data) return markers;
|
||||
var showMainBi = $('#showMainBiArea').is(':checked');
|
||||
var showMainSeg = $('#showMainSegArea').is(':checked');
|
||||
if (showMainBi || showMainSeg) {
|
||||
markers = markers.concat(collectAreaHistMarkers(
|
||||
data.bi_list,
|
||||
data.uncompleted_bi_list,
|
||||
data.seg_list,
|
||||
data.uncompleted_seg_list,
|
||||
{ color: '#1565c0', size: 0.55 },
|
||||
{ color: '#00838f', size: 0.65 },
|
||||
showMainBi,
|
||||
showMainSeg
|
||||
));
|
||||
}
|
||||
var showElementBi = $('#showElementBiArea').is(':checked');
|
||||
var showElementSeg = $('#showElementSegArea').is(':checked');
|
||||
if (showElementBi || showElementSeg) {
|
||||
markers = markers.concat(collectAreaHistMarkers(
|
||||
data.element_bi_list,
|
||||
data.element_uncompleted_bi_list,
|
||||
data.element_seg_list,
|
||||
data.element_uncompleted_seg_list,
|
||||
{ color: '#3949ab', size: 0.5 },
|
||||
{ color: '#5c6bc0', size: 0.6 },
|
||||
showElementBi,
|
||||
showElementSeg
|
||||
));
|
||||
}
|
||||
var showSubSubBi = $('#showSubSubBiArea').is(':checked');
|
||||
var showSubSubSeg = $('#showSubSubSegArea').is(':checked');
|
||||
if (showSubSubBi || showSubSubSeg) {
|
||||
markers = markers.concat(collectAreaHistMarkers(
|
||||
data.sub_sub_bi_list,
|
||||
data.sub_sub_uncompleted_bi_list,
|
||||
data.sub_sub_seg_list,
|
||||
data.sub_sub_uncompleted_seg_list,
|
||||
{ color: '#2e7d32', size: 0.45 },
|
||||
{ color: '#558b2f', size: 0.55 },
|
||||
showSubSubBi,
|
||||
showSubSubSeg
|
||||
));
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
function buildKlcTrendMarker(timeAligned, trendRaw, palette) {
|
||||
var kind = normalizeKlcTrendRaw(trendRaw);
|
||||
var style = palette[kind] || palette.UNKNOWN || palette.FLAT;
|
||||
@@ -136,6 +227,21 @@ function getMainPriceSeries() {
|
||||
s.lineSeries || s.areaSeries || s.baselineSeries || null;
|
||||
}
|
||||
|
||||
function plotLeftOffset(chart) {
|
||||
try {
|
||||
var left = chart && chart.priceScale && chart.priceScale('left');
|
||||
if (!left) return 0;
|
||||
var visible = true;
|
||||
try {
|
||||
var opts = left.options && left.options();
|
||||
if (opts && opts.visible === false) return 0;
|
||||
} catch (e) {}
|
||||
return (typeof left.width === 'function' ? left.width() : 0) || 0;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
|
||||
if (!mainChart || !mainChartContainer) return;
|
||||
if (typeof window._fxBoxOverlayCleanup === 'function') {
|
||||
@@ -163,23 +269,38 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
|
||||
// LWC 4 无 priceScale 订阅:采样坐标变化(含增量 setData 后自动缩放)
|
||||
var sampleSig = function () {
|
||||
var boxes = window._fxBoxVerticals || [];
|
||||
var labels = window._areaTextLabels || [];
|
||||
var series = getMainPriceSeries();
|
||||
if (!series || !boxes.length) return '0';
|
||||
if (!series || (!boxes.length && !labels.length)) return '0';
|
||||
var ts = mainChart.timeScale();
|
||||
var a = boxes[0];
|
||||
var b = boxes[boxes.length - 1];
|
||||
return [
|
||||
boxes.length,
|
||||
quant(ts.timeToCoordinate(a.time)),
|
||||
quant(series.priceToCoordinate(a.hi)),
|
||||
quant(series.priceToCoordinate(a.lo)),
|
||||
quant(ts.timeToCoordinate(b.time)),
|
||||
quant(series.priceToCoordinate(b.hi)),
|
||||
quant(series.priceToCoordinate(b.lo))
|
||||
].join('|');
|
||||
var parts = [boxes.length, labels.length, quant(plotLeftOffset(mainChart))];
|
||||
if (boxes.length) {
|
||||
var a = boxes[0];
|
||||
var b = boxes[boxes.length - 1];
|
||||
parts.push(
|
||||
quant(ts.timeToCoordinate(a.time)),
|
||||
quant(series.priceToCoordinate(a.hi)),
|
||||
quant(series.priceToCoordinate(a.lo)),
|
||||
quant(ts.timeToCoordinate(b.time)),
|
||||
quant(series.priceToCoordinate(b.hi)),
|
||||
quant(series.priceToCoordinate(b.lo))
|
||||
);
|
||||
}
|
||||
if (labels.length) {
|
||||
var la = labels[0];
|
||||
var lb = labels[labels.length - 1];
|
||||
parts.push(
|
||||
quant(ts.timeToCoordinate(la.time)),
|
||||
quant(series.priceToCoordinate(la.price)),
|
||||
quant(ts.timeToCoordinate(lb.time)),
|
||||
quant(series.priceToCoordinate(lb.price))
|
||||
);
|
||||
}
|
||||
return parts.join('|');
|
||||
};
|
||||
var redraw = function () {
|
||||
var boxes = window._fxBoxVerticals || [];
|
||||
var labels = window._areaTextLabels || [];
|
||||
var series = getMainPriceSeries();
|
||||
var rect = mainChartContainer.getBoundingClientRect();
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
@@ -191,26 +312,41 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
|
||||
if (!ctx2) return;
|
||||
ctx2.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx2.clearRect(0, 0, rect.width, rect.height);
|
||||
if (!series || !boxes.length) {
|
||||
if (!series || (!boxes.length && !labels.length)) {
|
||||
lastSig = sampleSig();
|
||||
return;
|
||||
}
|
||||
var ts = mainChart.timeScale();
|
||||
var x0 = plotLeftOffset(mainChart);
|
||||
for (var i = 0; i < boxes.length; i++) {
|
||||
var box = boxes[i];
|
||||
var x = ts.timeToCoordinate(box.time);
|
||||
var y1 = series.priceToCoordinate(box.hi);
|
||||
var y2 = series.priceToCoordinate(box.lo);
|
||||
if (x == null || y1 == null || y2 == null) continue;
|
||||
var px = Math.round(x + x0) + 0.5;
|
||||
ctx2.beginPath();
|
||||
ctx2.strokeStyle = box.color;
|
||||
ctx2.lineWidth = 1;
|
||||
ctx2.setLineDash([4, 3]);
|
||||
ctx2.moveTo(Math.round(x) + 0.5, y1);
|
||||
ctx2.lineTo(Math.round(x) + 0.5, y2);
|
||||
ctx2.moveTo(px, y1);
|
||||
ctx2.lineTo(px, y2);
|
||||
ctx2.stroke();
|
||||
}
|
||||
ctx2.setLineDash([]);
|
||||
if (labels.length) {
|
||||
ctx2.font = '11px sans-serif';
|
||||
ctx2.textAlign = 'center';
|
||||
for (var li = 0; li < labels.length; li++) {
|
||||
var lab = labels[li];
|
||||
var lx = ts.timeToCoordinate(lab.time);
|
||||
var ly = series.priceToCoordinate(lab.price);
|
||||
if (lx == null || ly == null) continue;
|
||||
ctx2.fillStyle = lab.color;
|
||||
ctx2.textBaseline = lab.above ? 'bottom' : 'top';
|
||||
ctx2.fillText(lab.text, Math.round(lx + x0), lab.above ? ly - 3 : ly + 3);
|
||||
}
|
||||
}
|
||||
lastSig = sampleSig();
|
||||
};
|
||||
var scheduleRedraw = function () {
|
||||
@@ -257,6 +393,7 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
|
||||
|
||||
function chartTvRenderOverlays(ctx) {
|
||||
window._fxBoxVerticals = [];
|
||||
window._areaTextLabels = [];
|
||||
var symbol = ctx.symbol;
|
||||
var timeframe = ctx.timeframe;
|
||||
var symbolConfig = ctx.symbolConfig;
|
||||
@@ -990,56 +1127,6 @@ function chartTvRenderOverlays(ctx) {
|
||||
window.bspMarkers = [];
|
||||
}
|
||||
|
||||
// 第四类买卖点(B4/S4):中枢突破回抽后当根入场,位置同 B3/S3 但早 7~8 根。
|
||||
// 与 BSP 分开收集,因为它数量远多于 B1/B2/B3,混在一个开关里图会糊掉。
|
||||
if ($('#showMainFastBsp').is(':checked') || $('#showElementFastBsp').is(':checked') || $('#showSubSubFastBsp').is(':checked')) {
|
||||
// 深色 = 区间套(大级别分型同向) + 中枢顺向推进都满足;浅色 = 未通过过滤
|
||||
const FAST_BSP_STYLE = {
|
||||
'BUY': { strong: '#FF6D00', weak: '#FFCC80', text: 'B4', position: 'belowBar' },
|
||||
'SELL': { strong: '#0091EA', weak: '#81D4FA', text: 'S4', position: 'aboveBar' },
|
||||
};
|
||||
const onlyFiltered = ($('#fastBspFilterMode').val() || 'all') === 'filtered';
|
||||
const allFastBspMarkers = [];
|
||||
|
||||
const collectFastBsp = function(list, prefix, label) {
|
||||
(list || []).forEach(function(bsp) {
|
||||
try {
|
||||
const ts = Math.floor(new Date(bsp.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
const style = FAST_BSP_STYLE[(bsp.dir || '').toUpperCase()];
|
||||
if (!style) return;
|
||||
const passed = !!(bsp.htf_agree && bsp.ladder_ok);
|
||||
if (onlyFiltered && !passed) return;
|
||||
allFastBspMarkers.push({
|
||||
time: ts,
|
||||
position: style.position,
|
||||
color: passed ? style.strong : style.weak,
|
||||
text: prefix + (passed ? style.text : style.text.toLowerCase()),
|
||||
size: passed ? 2 : 1
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(label + '第四类买卖点处理出错:', e);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if ($('#showMainFastBsp').is(':checked')) {
|
||||
collectFastBsp(currentData.fast_bsp_list, '', '主周期');
|
||||
}
|
||||
if ($('#showElementFastBsp').is(':checked')) {
|
||||
collectFastBsp(currentData.element_fast_bsp_list, 'e', '次周期');
|
||||
}
|
||||
if ($('#showSubSubFastBsp').is(':checked')) {
|
||||
collectFastBsp(currentData.sub_sub_fast_bsp_list, 's', '次次周期');
|
||||
}
|
||||
|
||||
allFastBspMarkers.sort((a, b) => a.time - b.time);
|
||||
window.fastBspMarkers = allFastBspMarkers;
|
||||
console.log(`绘制第四类买卖点,共${allFastBspMarkers.length}个标记(${onlyFiltered ? '仅过滤后' : '全部'})`);
|
||||
} else {
|
||||
window.fastBspMarkers = [];
|
||||
}
|
||||
|
||||
// 添加买卖点标记(旧版,保留兼容)
|
||||
// 这里为了与主面板上的「买卖点」开关保持一致,
|
||||
// 同时响应顶部的 `#showMainBsp` 复选框
|
||||
@@ -1615,7 +1702,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
lineStyle: 2, // 虚线
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
title: '次周期布林上轨'
|
||||
title: '次布林上轨'
|
||||
});
|
||||
elementUpperBandSeries.setData(elementUpperBandData);
|
||||
|
||||
@@ -1626,7 +1713,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
lineStyle: 2, // 虚线
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
title: '次周期布林下轨'
|
||||
title: '次布林下轨'
|
||||
});
|
||||
elementLowerBandSeries.setData(elementLowerBandData);
|
||||
|
||||
@@ -1636,7 +1723,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
lineWidth: 1,
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
title: '次周期布林中轨'
|
||||
title: '次布林中轨'
|
||||
});
|
||||
elementMiddleBandSeries.setData(elementMiddleBandData);
|
||||
|
||||
@@ -1751,7 +1838,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
const fxMarker = {
|
||||
time: timestamp,
|
||||
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
|
||||
主周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
|
||||
主${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
|
||||
强度分数: ${fx.fx_strength}分<br>
|
||||
强度等级: ${fx.fx_strength_level}<br>
|
||||
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
|
||||
@@ -1815,7 +1902,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
const fxMarker = {
|
||||
time: timestamp,
|
||||
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
|
||||
主周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
|
||||
主${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
|
||||
强度分数: ${fx.fx_strength}分<br>
|
||||
强度等级: ${fx.fx_strength_level}<br>
|
||||
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
|
||||
@@ -1852,6 +1939,14 @@ function chartTvRenderOverlays(ctx) {
|
||||
window.mainFxMarkers = [];
|
||||
window.fxMarkers = [];
|
||||
}
|
||||
window._areaTextLabels = alignMarkersToCandles(
|
||||
buildAreaHistMarkersFromData(currentData),
|
||||
candles
|
||||
);
|
||||
if (typeof window._redrawFxBoxVerticalOverlay === 'function') {
|
||||
window._redrawFxBoxVerticalOverlay();
|
||||
}
|
||||
|
||||
// 绘制小周期分型标记(含次次周期)
|
||||
if (($('#showElementKlcFxType').is(':checked') && currentData.element_klc_fx_info && currentData.element_klc_fx_info.length > 0) ||
|
||||
($('#showElementKluFxType').is(':checked') && currentData.element_klu_fx_info && currentData.element_klu_fx_info.length > 0) ||
|
||||
@@ -1940,7 +2035,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
const elementFxMarker = {
|
||||
time: timestamp,
|
||||
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
|
||||
小周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
|
||||
次${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
|
||||
强度分数: ${fx.fx_strength}分<br>
|
||||
强度等级: ${fx.fx_strength_level}<br>
|
||||
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
|
||||
@@ -1996,7 +2091,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
const elementFxMarker = {
|
||||
time: timestamp,
|
||||
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
|
||||
小周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
|
||||
次${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
|
||||
强度分数: ${fx.fx_strength}分<br>
|
||||
强度等级: ${fx.fx_strength_level}<br>
|
||||
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
|
||||
@@ -2157,7 +2252,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
trendMarkersToUse = trendMarkersToUse.concat(subSubMarkers);
|
||||
}
|
||||
|
||||
// 合并标记并设置
|
||||
// 合并标记并设置(主图不画 U/穿零轴,只留背驰 SD/CD)
|
||||
const combinedMarkers = [
|
||||
...(window.mainFxMarkers || []),
|
||||
...allElementFxMarkers,
|
||||
@@ -2165,17 +2260,15 @@ function chartTvRenderOverlays(ctx) {
|
||||
...(window.kluDivMarkersElement || []),
|
||||
...(window.kluDivMarkersSubSub || []),
|
||||
...trendMarkersToUse,
|
||||
...(window.bspMarkers || []),
|
||||
...(window.fastBspMarkers || [])
|
||||
...(window.bspMarkers || [])
|
||||
];
|
||||
if (combinedMarkers.length > 0) {
|
||||
console.log(
|
||||
'合并设置', combinedMarkers.length, '个标记(主周期分型:',
|
||||
(window.mainFxMarkers || []).length,
|
||||
'个,小周期分型:', allElementFxMarkers.length,
|
||||
'个,UnitTF:', (window.unittfMarkers || []).length,
|
||||
'个,背驰:', (window.kluDivMarkersMain || []).length,
|
||||
'个,BSP标记:', (window.bspMarkers || []).length,
|
||||
'个,第四类标记:', (window.fastBspMarkers || []).length,
|
||||
'个)'
|
||||
);
|
||||
|
||||
@@ -2277,18 +2370,17 @@ function chartTvRenderOverlays(ctx) {
|
||||
// 这里的 onlyMainAndU 实际上是「最终要挂到主K线上」的一组标记
|
||||
// 之前没有把 window.bspMarkers 合进去,导致上面已经合并了 BSP 标记,
|
||||
// 但在这里再次调用 setMarkers 时把 BSP 覆盖掉了,从而前端看不到买卖点。
|
||||
// 修复:把 BSP 标记一并合并进来。第四类买卖点同理,两处都要带上。
|
||||
// 修复:把 BSP 标记一并合并进来。两处都要带上,否则后面这次 setMarkers 会把买卖点盖掉。
|
||||
const onlyMainAndU = [
|
||||
...(window.mainFxMarkers || []),
|
||||
...(window.kluDivMarkersMain || []),
|
||||
...(window.kluDivMarkersElement || []),
|
||||
...(window.kluDivMarkersSubSub || []),
|
||||
...trendMarkersToUse,
|
||||
...(window.bspMarkers || []),
|
||||
...(window.fastBspMarkers || [])
|
||||
...(window.bspMarkers || [])
|
||||
];
|
||||
if (onlyMainAndU.length > 0) {
|
||||
console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, ',UnitTF:', (window.unittfMarkers || []).length, ')');
|
||||
console.log('仅设置', onlyMainAndU.length, '个主图标记(主周期分型:', (window.mainFxMarkers || []).length, ',背驰:', (window.kluDivMarkersMain || []).length, ')');
|
||||
|
||||
// 根据当前主系列类型设置标记
|
||||
const klineType2 = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
|
||||
|
||||
@@ -77,6 +77,7 @@ function chartTvBuildShell(ctx) {
|
||||
mainChart: null,
|
||||
volumeChart: null,
|
||||
macdChart: null,
|
||||
sentimentChart: null,
|
||||
series: {
|
||||
candleSeries: null,
|
||||
lineSeries: null,
|
||||
@@ -125,6 +126,7 @@ function chartTvBuildShell(ctx) {
|
||||
// 是否显示MACD
|
||||
const showMacd = $('#showMacd').is(':checked');
|
||||
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
||||
const showSentiment = ($('#dataSource').val() || 'crypto') === 'crypto' && $('#showDeriv').is(':checked');
|
||||
|
||||
// 创建主图容器
|
||||
const mainChartContainer = document.createElement('div');
|
||||
@@ -153,25 +155,20 @@ function chartTvBuildShell(ctx) {
|
||||
// 如果需要显示MACD,创建MACD容器
|
||||
let macdChartContainer = null;
|
||||
let chanMacdChartContainer = null;
|
||||
if (showMacd) {
|
||||
// 仅显示新的 ChanMACD 图:让其占用原 MACD+ChanMACD 的整体高度
|
||||
// 新布局:主图(40%) → ChanMACD(30%) → 成交量(17.5%) → ATR(12.5%)
|
||||
mainChartContainer.style.height = '40%';
|
||||
|
||||
// 隐藏旧 MACD 容器(不创建)
|
||||
// 创建 ChanMACD 容器占据原 MACD+ChanMACD 高度(30%)
|
||||
let sentimentChartContainer = null;
|
||||
if (showMacd && showSentiment) {
|
||||
mainChartContainer.style.height = '34%';
|
||||
chanMacdChartContainer = document.createElement('div');
|
||||
chanMacdChartContainer.style.width = '100%';
|
||||
chanMacdChartContainer.style.height = '30%';
|
||||
chanMacdChartContainer.style.height = 'calc(22% - 50px)';
|
||||
chanMacdChartContainer.style.position = 'absolute';
|
||||
chanMacdChartContainer.style.top = '40%';
|
||||
chanMacdChartContainer.style.top = '34%';
|
||||
chanMacdChartContainer.style.left = '0';
|
||||
chanMacdChartContainer.style.right = '0';
|
||||
chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0';
|
||||
chanMacdChartContainer.style.zIndex = '10';
|
||||
// 水印:便于区分是新的 ChanMACD 子图
|
||||
const chanMacdWatermark = document.createElement('div');
|
||||
chanMacdWatermark.textContent = 'ChanMACD';
|
||||
chanMacdWatermark.textContent = 'MACD';
|
||||
chanMacdWatermark.style.position = 'absolute';
|
||||
chanMacdWatermark.style.top = '4px';
|
||||
chanMacdWatermark.style.left = '8px';
|
||||
@@ -179,31 +176,81 @@ function chartTvBuildShell(ctx) {
|
||||
chanMacdWatermark.style.color = '#888';
|
||||
chanMacdWatermark.style.pointerEvents = 'none';
|
||||
chanMacdChartContainer.appendChild(chanMacdWatermark);
|
||||
|
||||
// 成交量位于 ChanMACD 之下
|
||||
volumeChartContainer.style.top = '70%';
|
||||
volumeChartContainer.style.height = '17.5%';
|
||||
|
||||
// ATR 位于最底部
|
||||
atrChartContainer.style.top = '87.5%';
|
||||
atrChartContainer.style.height = '12.5%';
|
||||
volumeChartContainer.style.top = 'calc(56% - 50px)';
|
||||
volumeChartContainer.style.height = 'calc(12% + 20px)';
|
||||
atrChartContainer.style.top = 'calc(68% - 30px)';
|
||||
atrChartContainer.style.height = 'calc(10% - 20px)';
|
||||
} else if (showMacd) {
|
||||
mainChartContainer.style.height = '40%';
|
||||
chanMacdChartContainer = document.createElement('div');
|
||||
chanMacdChartContainer.style.width = '100%';
|
||||
chanMacdChartContainer.style.height = 'calc(30% - 50px)';
|
||||
chanMacdChartContainer.style.position = 'absolute';
|
||||
chanMacdChartContainer.style.top = '40%';
|
||||
chanMacdChartContainer.style.left = '0';
|
||||
chanMacdChartContainer.style.right = '0';
|
||||
chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0';
|
||||
chanMacdChartContainer.style.zIndex = '10';
|
||||
const chanMacdWatermark = document.createElement('div');
|
||||
chanMacdWatermark.textContent = 'MACD';
|
||||
chanMacdWatermark.style.position = 'absolute';
|
||||
chanMacdWatermark.style.top = '4px';
|
||||
chanMacdWatermark.style.left = '8px';
|
||||
chanMacdWatermark.style.fontSize = '11px';
|
||||
chanMacdWatermark.style.color = '#888';
|
||||
chanMacdWatermark.style.pointerEvents = 'none';
|
||||
chanMacdChartContainer.appendChild(chanMacdWatermark);
|
||||
volumeChartContainer.style.top = 'calc(70% - 50px)';
|
||||
volumeChartContainer.style.height = 'calc(17.5% + 20px)';
|
||||
atrChartContainer.style.top = 'calc(87.5% - 30px)';
|
||||
atrChartContainer.style.height = 'calc(12.5% - 20px)';
|
||||
} else if (showSentiment) {
|
||||
mainChartContainer.style.height = '48%';
|
||||
volumeChartContainer.style.top = '48%';
|
||||
volumeChartContainer.style.height = '14%';
|
||||
atrChartContainer.style.top = '62%';
|
||||
atrChartContainer.style.height = '12%';
|
||||
} else {
|
||||
// 不显示MACD时的高度 - 主图、成交量图和ATR图分配
|
||||
mainChartContainer.style.height = '55%'; // 主图占55%
|
||||
mainChartContainer.style.height = '55%';
|
||||
volumeChartContainer.style.top = '55%';
|
||||
volumeChartContainer.style.height = '22.5%'; // 成交量图占22.5%
|
||||
|
||||
atrChartContainer.style.top = '77.5%'; // ATR图从77.5%位置开始
|
||||
atrChartContainer.style.height = '22.5%'; // ATR图占22.5%
|
||||
volumeChartContainer.style.height = '22.5%';
|
||||
atrChartContainer.style.top = '77.5%';
|
||||
atrChartContainer.style.height = '22.5%';
|
||||
}
|
||||
if (showSentiment) {
|
||||
sentimentChartContainer = document.createElement('div');
|
||||
sentimentChartContainer.style.width = '100%';
|
||||
sentimentChartContainer.style.position = 'absolute';
|
||||
sentimentChartContainer.style.left = '0';
|
||||
sentimentChartContainer.style.right = '0';
|
||||
sentimentChartContainer.style.borderTop = '1px solid #e0e0e0';
|
||||
if (showMacd) {
|
||||
sentimentChartContainer.style.top = '78%';
|
||||
sentimentChartContainer.style.height = '22%';
|
||||
} else {
|
||||
sentimentChartContainer.style.top = '74%';
|
||||
sentimentChartContainer.style.height = '26%';
|
||||
}
|
||||
const sentimentWatermark = document.createElement('div');
|
||||
sentimentWatermark.textContent = '衍生品 买卖比 / 多空 / 大户 / 费率';
|
||||
sentimentWatermark.style.position = 'absolute';
|
||||
sentimentWatermark.style.top = '4px';
|
||||
sentimentWatermark.style.left = '8px';
|
||||
sentimentWatermark.style.fontSize = '11px';
|
||||
sentimentWatermark.style.color = '#888';
|
||||
sentimentWatermark.style.pointerEvents = 'none';
|
||||
sentimentChartContainer.appendChild(sentimentWatermark);
|
||||
}
|
||||
|
||||
container.appendChild(mainChartContainer);
|
||||
container.appendChild(volumeChartContainer);
|
||||
container.appendChild(atrChartContainer);
|
||||
if (showMacd) {
|
||||
// 只追加新的 ChanMACD 容器
|
||||
container.appendChild(chanMacdChartContainer);
|
||||
}
|
||||
if (showSentiment && sentimentChartContainer) {
|
||||
container.appendChild(sentimentChartContainer);
|
||||
}
|
||||
|
||||
// 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责)
|
||||
|
||||
@@ -221,6 +268,8 @@ function chartTvBuildShell(ctx) {
|
||||
chartHeight = macdChartContainer ? macdChartContainer.clientHeight : 0;
|
||||
} else if (chartType === 'chanmacd') {
|
||||
chartHeight = chanMacdChartContainer ? chanMacdChartContainer.clientHeight : 0;
|
||||
} else if (chartType === 'sentiment') {
|
||||
chartHeight = sentimentChartContainer ? sentimentChartContainer.clientHeight : 0;
|
||||
} else {
|
||||
chartHeight = mainChartContainer.clientHeight;
|
||||
}
|
||||
@@ -376,9 +425,26 @@ function chartTvBuildShell(ctx) {
|
||||
// 创建MACD图表(如果需要):仅创建新的 ChanMACD 图
|
||||
let macdChart = null;
|
||||
let chanMacdChart = null;
|
||||
let sentimentChart = null;
|
||||
if (showMacd) {
|
||||
chanMacdChart = LightweightCharts.createChart(chanMacdChartContainer, createChartOptions(false, 'chanmacd'));
|
||||
}
|
||||
if (showSentiment && sentimentChartContainer) {
|
||||
sentimentChart = LightweightCharts.createChart(sentimentChartContainer, createChartOptions(false, 'sentiment'));
|
||||
if (candles.length) {
|
||||
const axisSeries = sentimentChart.addLineSeries({
|
||||
priceScaleId: '__time',
|
||||
color: 'rgba(0,0,0,0)',
|
||||
lineWidth: 0,
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
crosshairMarkerVisible: false
|
||||
});
|
||||
sentimentChart.priceScale('__time').applyOptions({ visible: false });
|
||||
axisSeries.setData(candles.map(function (c) { return { time: c.time, value: 0 }; }));
|
||||
tvWidget.series.sentimentAxisSeries = axisSeries;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建主价格系列并设置数据(支持多种图表类型)
|
||||
(function(){
|
||||
@@ -495,10 +561,13 @@ function chartTvBuildShell(ctx) {
|
||||
ctx.atrChartContainer = atrChartContainer;
|
||||
ctx.macdChartContainer = macdChartContainer;
|
||||
ctx.chanMacdChartContainer = chanMacdChartContainer;
|
||||
ctx.sentimentChartContainer = sentimentChartContainer;
|
||||
ctx.showSentiment = showSentiment;
|
||||
ctx.mainChart = mainChart;
|
||||
ctx.volumeChart = volumeChart;
|
||||
ctx.atrChart = atrChart;
|
||||
ctx.macdChart = macdChart;
|
||||
ctx.chanMacdChart = chanMacdChart;
|
||||
ctx.sentimentChart = sentimentChart;
|
||||
ctx.createChartOptions = createChartOptions;
|
||||
}
|
||||
|
||||
@@ -74,10 +74,10 @@ function readChartFormContext() {
|
||||
return {
|
||||
dataSource: dataSource,
|
||||
symbol: symbol,
|
||||
timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h',
|
||||
timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '45m',
|
||||
timezone: $('#timezone').val() || 'Asia/Shanghai',
|
||||
elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m',
|
||||
subSubTimeframe: $('#subSubTimeframe').val() || '',
|
||||
elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '15m',
|
||||
subSubTimeframe: $('#subSubTimeframe').val() || window.DEFAULT_SUB_SUB_TIMEFRAME || '5m',
|
||||
startTimeMs: $('#start_time').val() ? new Date($('#start_time').val()).getTime() : null,
|
||||
endTimeMs: $('#end_time').val() ? new Date($('#end_time').val()).getTime() : null
|
||||
};
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
/* 资金面 + 情绪面。只打本站中转。OI 叠主图左侧,其余叠情绪副图。 */
|
||||
window.ChanDeriv = (function () {
|
||||
var snapshotReq = 0;
|
||||
var latestReq = 0;
|
||||
var overlayReq = 0;
|
||||
var caches = {};
|
||||
|
||||
var METRICS = [
|
||||
{
|
||||
id: 'oi',
|
||||
checkbox: 'showOi',
|
||||
metric: 'open_interest_history',
|
||||
field: 'open_interest_amount',
|
||||
seriesKey: 'oiSeries',
|
||||
target: 'main',
|
||||
color: 'rgba(123, 31, 162, 0.85)',
|
||||
title: 'OI',
|
||||
priceFormat: { type: 'volume' }
|
||||
},
|
||||
{
|
||||
id: 'taker',
|
||||
checkbox: 'showDeriv',
|
||||
metric: 'taker_buy_sell_ratio',
|
||||
field: 'buy_sell_ratio',
|
||||
seriesKey: 'takerSeries',
|
||||
target: 'sentiment',
|
||||
color: '#0d9488',
|
||||
title: '买卖比',
|
||||
priceFormat: { type: 'price', precision: 3 }
|
||||
},
|
||||
{
|
||||
id: 'lsAccount',
|
||||
checkbox: 'showDeriv',
|
||||
metric: 'long_short_account_ratio',
|
||||
field: 'long_short_ratio',
|
||||
seriesKey: 'lsAccountSeries',
|
||||
target: 'sentiment',
|
||||
color: '#2563eb',
|
||||
title: '多空',
|
||||
priceFormat: { type: 'price', precision: 3 }
|
||||
},
|
||||
{
|
||||
id: 'lsTop',
|
||||
checkbox: 'showDeriv',
|
||||
metric: 'top_long_short_position_ratio',
|
||||
field: 'long_short_ratio',
|
||||
seriesKey: 'lsTopSeries',
|
||||
target: 'sentiment',
|
||||
color: '#ea580c',
|
||||
title: '大户',
|
||||
priceFormat: { type: 'price', precision: 3 }
|
||||
},
|
||||
{
|
||||
id: 'funding',
|
||||
checkbox: 'showDeriv',
|
||||
metric: 'funding_rate_history',
|
||||
field: 'funding_rate',
|
||||
seriesKey: 'fundingHistSeries',
|
||||
target: 'sentiment',
|
||||
color: '#7c3aed',
|
||||
title: '费率%',
|
||||
scale: 'funding',
|
||||
mul: 100,
|
||||
priceFormat: { type: 'price', precision: 4 }
|
||||
}
|
||||
];
|
||||
|
||||
function isCrypto() {
|
||||
return ($('#dataSource').val() || 'crypto') === 'crypto';
|
||||
}
|
||||
|
||||
function currentSymbol() {
|
||||
return $('#symbol').val() || 'BTC/USDT:USDT';
|
||||
}
|
||||
|
||||
function fmtOi(n) {
|
||||
if (n == null || !isFinite(Number(n))) return '—';
|
||||
return Number(n).toLocaleString('en-US', { maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
function fmtFunding(n) {
|
||||
if (n == null || !isFinite(Number(n))) return '—';
|
||||
return (Number(n) * 100).toFixed(4) + '%';
|
||||
}
|
||||
|
||||
function fmtChg(n) {
|
||||
if (n == null || !isFinite(Number(n))) return '—';
|
||||
var v = Number(n);
|
||||
return (v > 0 ? '+' : '') + v.toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function fmtBasis(n) {
|
||||
if (n == null || !isFinite(Number(n))) return '—';
|
||||
var v = Number(n);
|
||||
return (v > 0 ? '+' : '') + v.toFixed(2);
|
||||
}
|
||||
|
||||
function fmtRatio(n) {
|
||||
if (n == null || !isFinite(Number(n))) return '—';
|
||||
return Number(n).toFixed(3);
|
||||
}
|
||||
|
||||
function setChip(id, text, tone) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.remove('up', 'down');
|
||||
if (tone) el.classList.add(tone);
|
||||
}
|
||||
|
||||
function tone(n, invert) {
|
||||
if (!isFinite(n) || n === 0) return null;
|
||||
var up = n > 0;
|
||||
if (invert) up = !up;
|
||||
return up ? 'up' : 'down';
|
||||
}
|
||||
|
||||
function renderDerivEmpty() {
|
||||
setChip('derivOi', 'OI —');
|
||||
setChip('derivOiChg', 'Δ —');
|
||||
setChip('derivFunding', '费率 —');
|
||||
setChip('derivBasis', '基差 —');
|
||||
var src = document.getElementById('derivSrc');
|
||||
if (src) src.textContent = '';
|
||||
}
|
||||
|
||||
function renderSentimentEmpty() {
|
||||
setChip('derivTaker', '买卖比 —');
|
||||
setChip('derivLs', '多空 —');
|
||||
setChip('derivTop', '大户 —');
|
||||
}
|
||||
|
||||
function setVisible(on) {
|
||||
var bar = document.getElementById('derivBar');
|
||||
var wrap = document.getElementById('showSentimentWrap');
|
||||
if (bar) bar.style.display = on ? '' : 'none';
|
||||
if (wrap) wrap.style.display = on ? '' : 'none';
|
||||
if (!on) clearAllSeries();
|
||||
}
|
||||
|
||||
function activeKlines() {
|
||||
var data = (typeof currentData !== 'undefined') ? currentData : null;
|
||||
if (!data) return [];
|
||||
if ($('#subSubPeriodKline').is(':checked') && data.sub_sub_kline_data) return data.sub_sub_kline_data;
|
||||
if ($('#elementPeriodKline').is(':checked') && data.element_kline_data) return data.element_kline_data;
|
||||
return data.kline_data || [];
|
||||
}
|
||||
|
||||
function klineRangeMs() {
|
||||
var rows = activeKlines();
|
||||
if (!rows.length) return null;
|
||||
var start = new Date(rows[0].date).getTime();
|
||||
var end = new Date(rows[rows.length - 1].date).getTime();
|
||||
if (!isFinite(start) || !isFinite(end)) return null;
|
||||
return { start: start, end: end };
|
||||
}
|
||||
|
||||
function toPoints(rows, field, mul) {
|
||||
var out = [];
|
||||
var lastT = null;
|
||||
var factor = mul || 1;
|
||||
(rows || []).forEach(function (row) {
|
||||
var ms = Number(row.timestamp);
|
||||
var val = Number(row[field]);
|
||||
if (!isFinite(ms) || !isFinite(val)) return;
|
||||
var t = Math.floor(ms / 1000);
|
||||
var v = val * factor;
|
||||
if (lastT === t) {
|
||||
out[out.length - 1].value = v;
|
||||
return;
|
||||
}
|
||||
lastT = t;
|
||||
out.push({ time: t, value: v });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function candleTimesSec() {
|
||||
var rows = activeKlines();
|
||||
var times = [];
|
||||
(rows || []).forEach(function (k) {
|
||||
var t = Math.floor(new Date(k.date).getTime() / 1000);
|
||||
if (isFinite(t)) times.push(t);
|
||||
});
|
||||
return times;
|
||||
}
|
||||
|
||||
function alignToTimes(src, times) {
|
||||
if (!src || !src.length || !times || !times.length) return [];
|
||||
var out = [];
|
||||
var j = 0;
|
||||
var lastVal;
|
||||
for (var i = 0; i < times.length; i++) {
|
||||
var t = times[i];
|
||||
while (j < src.length && src[j].time <= t) {
|
||||
lastVal = src[j].value;
|
||||
j++;
|
||||
}
|
||||
if (lastVal !== undefined) out.push({ time: t, value: lastVal });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function syncSentimentTime() {
|
||||
if (!tvWidget || !tvWidget.mainChart || !tvWidget.sentimentChart) return;
|
||||
try {
|
||||
var vr = tvWidget.mainChart.timeScale().getVisibleRange();
|
||||
var lr = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
|
||||
var opts = tvWidget.mainChart.timeScale().options ? tvWidget.mainChart.timeScale().options() : null;
|
||||
if (opts) {
|
||||
tvWidget.sentimentChart.timeScale().applyOptions({
|
||||
barSpacing: opts.barSpacing,
|
||||
rightOffset: opts.rightOffset
|
||||
});
|
||||
}
|
||||
if (vr) tvWidget.sentimentChart.timeScale().setVisibleRange(vr);
|
||||
if (lr) tvWidget.sentimentChart.timeScale().setVisibleLogicalRange(lr);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function targetChart(spec) {
|
||||
if (!tvWidget) return null;
|
||||
if (spec.target === 'main') return tvWidget.mainChart;
|
||||
return tvWidget.sentimentChart || null;
|
||||
}
|
||||
|
||||
function safeRemove(chart, seriesKey) {
|
||||
var series = tvWidget && tvWidget.series && tvWidget.series[seriesKey];
|
||||
if (series && chart) {
|
||||
try { chart.removeSeries(series); } catch (e) {}
|
||||
}
|
||||
if (tvWidget && tvWidget.series) tvWidget.series[seriesKey] = null;
|
||||
}
|
||||
|
||||
function clearAllSeries() {
|
||||
METRICS.forEach(function (spec) {
|
||||
safeRemove(targetChart(spec), spec.seriesKey);
|
||||
});
|
||||
safeRemove(tvWidget && tvWidget.sentimentChart, 'sentimentBaseSeries');
|
||||
try {
|
||||
if (tvWidget && tvWidget.mainChart) {
|
||||
tvWidget.mainChart.applyOptions({ leftPriceScale: { visible: false } });
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function drawSeries(spec, points) {
|
||||
var chart = targetChart(spec);
|
||||
if (!chart || !points || !points.length) return;
|
||||
safeRemove(chart, spec.seriesKey);
|
||||
if (spec.target === 'main') {
|
||||
chart.applyOptions({
|
||||
leftPriceScale: {
|
||||
visible: true,
|
||||
borderVisible: false,
|
||||
scaleMargins: { top: 0.08, bottom: 0.12 }
|
||||
}
|
||||
});
|
||||
}
|
||||
var opts = {
|
||||
color: spec.color,
|
||||
lineWidth: 1,
|
||||
title: spec.title,
|
||||
lastValueVisible: true,
|
||||
priceLineVisible: false,
|
||||
priceFormat: spec.priceFormat
|
||||
};
|
||||
if (spec.target === 'main') opts.priceScaleId = 'left';
|
||||
if (spec.scale) {
|
||||
opts.priceScaleId = spec.scale;
|
||||
chart.priceScale(spec.scale).applyOptions({
|
||||
scaleMargins: { top: 0.15, bottom: 0.1 },
|
||||
borderVisible: false
|
||||
});
|
||||
}
|
||||
var series = chart.addLineSeries(opts);
|
||||
series.setData(points);
|
||||
tvWidget.series[spec.seriesKey] = series;
|
||||
if (spec.target === 'sentiment') syncSentimentTime();
|
||||
if (spec.target === 'main' && typeof window._redrawFxBoxVerticalOverlay === 'function') {
|
||||
window._redrawFxBoxVerticalOverlay();
|
||||
setTimeout(window._redrawFxBoxVerticalOverlay, 50);
|
||||
}
|
||||
}
|
||||
|
||||
function drawRatioBaseline(points) {
|
||||
var chart = tvWidget && tvWidget.sentimentChart;
|
||||
if (!chart || !points || !points.length) return;
|
||||
safeRemove(chart, 'sentimentBaseSeries');
|
||||
var baseline = points.map(function (p) { return { time: p.time, value: 1 }; });
|
||||
var series = chart.addLineSeries({
|
||||
color: 'rgba(120, 120, 120, 0.45)',
|
||||
lineWidth: 1,
|
||||
lineStyle: 2,
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
title: '1.0'
|
||||
});
|
||||
series.setData(baseline);
|
||||
tvWidget.series.sentimentBaseSeries = series;
|
||||
}
|
||||
|
||||
function loadOne(spec, range, symbol, req) {
|
||||
var checked = $('#' + spec.checkbox).is(':checked');
|
||||
var chart = targetChart(spec);
|
||||
if (!checked) {
|
||||
safeRemove(chart, spec.seriesKey);
|
||||
if (spec.id === 'oi') {
|
||||
try {
|
||||
if (tvWidget && tvWidget.mainChart) {
|
||||
tvWidget.mainChart.applyOptions({ leftPriceScale: { visible: false } });
|
||||
}
|
||||
} catch (e) {}
|
||||
if (typeof window._redrawFxBoxVerticalOverlay === 'function') {
|
||||
window._redrawFxBoxVerticalOverlay();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!chart) return;
|
||||
var times = candleTimesSec();
|
||||
var key = [symbol, spec.metric, times[0] || '', times[times.length - 1] || '', times.length].join(':');
|
||||
var cached = caches[spec.id];
|
||||
function paint(raw) {
|
||||
var aligned = spec.target === 'main' || spec.target === 'sentiment' ? alignToTimes(raw, times) : raw;
|
||||
if (!aligned.length) aligned = raw;
|
||||
drawSeries(spec, aligned);
|
||||
if (spec.target === 'sentiment') maybeDrawBaseline();
|
||||
}
|
||||
if (cached && cached.raw && cached.raw.length) {
|
||||
paint(cached.raw);
|
||||
if (cached.key === key) return;
|
||||
}
|
||||
if (!range || !window.ChanApi || !ChanApi.sentimentMetrics) return;
|
||||
ChanApi.sentimentMetrics({
|
||||
metric: spec.metric,
|
||||
symbol: symbol,
|
||||
start: range.start - 8 * 60 * 60 * 1000,
|
||||
end: range.end,
|
||||
limit: 2000
|
||||
}).then(function (payload) {
|
||||
if (req !== overlayReq) return;
|
||||
var raw = toPoints(payload && payload.data, spec.field, spec.mul);
|
||||
if (!raw.length) return;
|
||||
caches[spec.id] = { key: key, raw: raw };
|
||||
if ($('#' + spec.checkbox).is(':checked') && targetChart(spec)) paint(raw);
|
||||
}).catch(function (err) {
|
||||
if (req !== overlayReq) return;
|
||||
console.warn(spec.title + ' 序列不可用', err);
|
||||
});
|
||||
}
|
||||
|
||||
function maybeDrawBaseline() {
|
||||
var times = candleTimesSec();
|
||||
if (!times.length) return;
|
||||
drawRatioBaseline(times.map(function (t) { return { time: t, value: 1 }; }));
|
||||
syncSentimentTime();
|
||||
}
|
||||
|
||||
function loadOverlays() {
|
||||
if (!isCrypto()) {
|
||||
clearAllSeries();
|
||||
return;
|
||||
}
|
||||
if (!tvWidget || !tvWidget.mainChart) return;
|
||||
var range = klineRangeMs();
|
||||
var symbol = currentSymbol();
|
||||
var req = ++overlayReq;
|
||||
METRICS.forEach(function (spec) {
|
||||
loadOne(spec, range, symbol, req);
|
||||
});
|
||||
maybeDrawBaseline();
|
||||
}
|
||||
|
||||
function loadSnapshot() {
|
||||
if (!isCrypto() || !window.ChanApi || !ChanApi.derivatives) return;
|
||||
var req = ++snapshotReq;
|
||||
ChanApi.derivatives({ symbol: currentSymbol() }).then(function (row) {
|
||||
if (req !== snapshotReq) return;
|
||||
var chg = Number(row.oi_change_pct);
|
||||
var fund = Number(row.funding_rate);
|
||||
var basis = Number(row.basis);
|
||||
setChip('derivOi', 'OI ' + fmtOi(row.open_interest));
|
||||
setChip('derivOiChg', 'Δ ' + fmtChg(row.oi_change_pct), tone(chg));
|
||||
setChip('derivFunding', '费率 ' + fmtFunding(row.funding_rate), tone(fund));
|
||||
setChip('derivBasis', '基差 ' + fmtBasis(row.basis), tone(basis));
|
||||
var src = document.getElementById('derivSrc');
|
||||
if (src) src.textContent = row.exchange || '';
|
||||
}).catch(function (err) {
|
||||
if (req !== snapshotReq) return;
|
||||
console.warn('资金面快照不可用', err);
|
||||
renderDerivEmpty();
|
||||
var src = document.getElementById('derivSrc');
|
||||
if (src) src.textContent = '不可用';
|
||||
});
|
||||
}
|
||||
|
||||
function loadLatest() {
|
||||
if (!isCrypto() || !window.ChanApi || !ChanApi.sentimentLatest) return;
|
||||
var req = ++latestReq;
|
||||
ChanApi.sentimentLatest({ symbol: currentSymbol() }).then(function (payload) {
|
||||
if (req !== latestReq) return;
|
||||
var data = (payload && payload.data) || {};
|
||||
var taker = data.taker_buy_sell_ratio || {};
|
||||
var ls = data.long_short_account_ratio || {};
|
||||
var top = data.top_long_short_position_ratio || {};
|
||||
var takerN = Number(taker.buy_sell_ratio);
|
||||
var lsN = Number(ls.long_short_ratio);
|
||||
var topN = Number(top.long_short_ratio);
|
||||
setChip('derivTaker', '买卖比 ' + fmtRatio(taker.buy_sell_ratio), isFinite(takerN) ? (takerN >= 1 ? 'up' : 'down') : null);
|
||||
setChip('derivLs', '多空 ' + fmtRatio(ls.long_short_ratio), isFinite(lsN) ? (lsN >= 1 ? 'up' : 'down') : null);
|
||||
setChip('derivTop', '大户 ' + fmtRatio(top.long_short_ratio), isFinite(topN) ? (topN >= 1 ? 'up' : 'down') : null);
|
||||
}).catch(function (err) {
|
||||
if (req !== latestReq) return;
|
||||
console.warn('情绪快照不可用', err);
|
||||
renderSentimentEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
function sync(opts) {
|
||||
opts = opts || {};
|
||||
var show = isCrypto();
|
||||
setVisible(show);
|
||||
if (!show) return;
|
||||
loadSnapshot();
|
||||
loadLatest();
|
||||
if (opts.overlay !== false) loadOverlays();
|
||||
}
|
||||
|
||||
return {
|
||||
sync: sync,
|
||||
setVisible: setVisible,
|
||||
loadOiOverlay: loadOverlays,
|
||||
loadOverlays: loadOverlays
|
||||
};
|
||||
})();
|
||||
@@ -151,7 +151,7 @@ $('#elementTimeframe').change(function() {
|
||||
|
||||
// 检查选择的元素时间周期是否小于等于主周期
|
||||
if (compareTimeframes(elementTimeframe, mainTimeframe) > 0) {
|
||||
alert('元素时间周期必须小于或等于主图表时间周期。');
|
||||
alert('次必须小于或等于主。');
|
||||
setSmallerOrEqualTimeframe(); // 重置为最大的小于等于时间周期
|
||||
return;
|
||||
}
|
||||
@@ -164,7 +164,7 @@ $('#subSubTimeframe').change(function() {
|
||||
const subSub = $(this).val();
|
||||
const elementTf = $('#elementTimeframe').val();
|
||||
if (compareTimeframes(subSub, elementTf) > 0) {
|
||||
alert('次次周期必须小于或等于次周期。');
|
||||
alert('次次必须小于或等于次。');
|
||||
ensureSubSubLteElement();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,67 @@ function clearChanMacdMarkers() {
|
||||
}
|
||||
// 清空全局UnitTF标记,避免旧数据残留影响主图合并
|
||||
window.unittfMarkers = [];
|
||||
window.unittfMarkersMain = [];
|
||||
window.unittfMarkersElement = [];
|
||||
window.unittfMarkersSubSub = [];
|
||||
}
|
||||
|
||||
function unittfDirSign(dir) {
|
||||
if (dir === 'ABOVE' || dir === 1 || dir === '1' || dir === true) return 1;
|
||||
if (dir === 'UNDER' || dir === -1 || dir === '-1') return -1;
|
||||
const n = Number(dir);
|
||||
if (n > 0) return 1;
|
||||
if (n < 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildUnittfOverlayMarkers(unittfList, opt) {
|
||||
const markers = [];
|
||||
if (!Array.isArray(unittfList) || !opt) return markers;
|
||||
const up = opt.up, down = opt.down, prefix = opt.prefix || 'U';
|
||||
const size = opt.size || 0.5;
|
||||
for (let i = 0; i < unittfList.length; i++) {
|
||||
const unittf = unittfList[i];
|
||||
if (!unittf || !unittf.start_time || unittf.invalid) continue;
|
||||
const startTime = Math.floor(new Date(unittf.start_time).getTime() / 1000);
|
||||
if (isNaN(startTime)) continue;
|
||||
const sign = unittfDirSign(unittf.dir);
|
||||
const color = sign > 0 ? up : down;
|
||||
const pos = sign > 0 ? 'aboveBar' : 'belowBar';
|
||||
markers.push({ time: startTime, position: pos, color: color, shape: 'circle', text: prefix + i, size: size });
|
||||
if (unittf.end_time) {
|
||||
const endTime = Math.floor(new Date(unittf.end_time).getTime() / 1000);
|
||||
if (!isNaN(endTime)) {
|
||||
markers.push({ time: endTime, position: pos, color: color, shape: 'circle', text: prefix + i + 'E', size: size });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i + 1 < unittfList.length; i++) {
|
||||
const cur = unittfList[i];
|
||||
const nxt = unittfList[i + 1];
|
||||
if (!cur || !nxt || !cur.end_time || !nxt.start_time) continue;
|
||||
const tEnd = new Date(cur.end_time).getTime();
|
||||
const tStart = new Date(nxt.start_time).getTime();
|
||||
if (!isNaN(tEnd) && tEnd === tStart) {
|
||||
const sign = unittfDirSign(nxt.dir);
|
||||
markers.push({
|
||||
time: Math.floor(tEnd / 1000),
|
||||
position: sign > 0 ? 'aboveBar' : 'belowBar',
|
||||
color: sign > 0 ? (opt.boundaryUp || up) : (opt.boundaryDown || down),
|
||||
shape: 'square',
|
||||
text: prefix + '↔',
|
||||
size: 0.6
|
||||
});
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
function refreshUnittfOverlayFromData(data) {
|
||||
// U/穿零轴只画在 MACD 副图,不再铺到主图
|
||||
window.unittfMarkersMain = [];
|
||||
window.unittfMarkersElement = [];
|
||||
window.unittfMarkersSubSub = [];
|
||||
}
|
||||
// 添加所有ChanMACD标记
|
||||
function addAllChanMacdMarkers(segList, unittfList, histsetList, stateMarkers) {
|
||||
@@ -215,8 +276,7 @@ function addAllChanMacdMarkers(segList, unittfList, histsetList, stateMarkers) {
|
||||
|
||||
// 保存到全局,供主图与分型一起统一合并绘制(仅在开关开启时)
|
||||
console.log('DEBUG: U 标记数量:', signalMarkers.length);
|
||||
const allowUMerge = (window.showUOnMain && window.showUOnElement);
|
||||
window.unittfMarkers = allowUMerge ? [...signalMarkers, ...boundaryMarkers] : [];
|
||||
window.unittfMarkers = [...signalMarkers, ...boundaryMarkers];
|
||||
if (uTooltipMarkers.length > 0) {
|
||||
if (window.fxMarkers) {
|
||||
window.fxMarkers = [ ...window.fxMarkers, ...uTooltipMarkers ];
|
||||
|
||||
+17
-15
@@ -26,10 +26,10 @@ function loadSymbols() {
|
||||
});
|
||||
}
|
||||
|
||||
// 设置默认时间范围:最近 1 个月
|
||||
// 设置默认时间范围:现在倒退 1 周
|
||||
function setDefaultTimeRange() {
|
||||
const now = new Date();
|
||||
const daysBack = 30;
|
||||
const daysBack = 7;
|
||||
const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
|
||||
|
||||
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
|
||||
@@ -72,17 +72,20 @@ $(document).ready(function() {
|
||||
window.astockStatusInterval = null;
|
||||
}
|
||||
loadSymbols();
|
||||
if (window.ChanDeriv) ChanDeriv.setVisible(true);
|
||||
} else if (dataSource === 'a_stock') {
|
||||
$('#cryptoSymbolContainer').hide();
|
||||
$('#astockSymbolContainer').show();
|
||||
loadAStockSymbols();
|
||||
startAStockStatusUpdater();
|
||||
if (window.ChanDeriv) ChanDeriv.setVisible(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 检查初始数据源设置
|
||||
const initialDataSource = $('#dataSource').val();
|
||||
if (window.ChanDeriv) ChanDeriv.setVisible(initialDataSource !== 'a_stock');
|
||||
if (initialDataSource === 'a_stock') {
|
||||
$.getJSON('/api/chart_metadata', { source: 'a_stock' })
|
||||
.done(function(meta) {
|
||||
@@ -404,6 +407,7 @@ function mapTimeframeToInterval(timeframe) {
|
||||
'5m': '5',
|
||||
'15m': '15',
|
||||
'30m': '30',
|
||||
'45m': '45',
|
||||
'1h': '60',
|
||||
'2h': '120',
|
||||
'4h': '240',
|
||||
@@ -547,6 +551,7 @@ function refreshChart(data, options) {
|
||||
if (currentData && currentData.ema52_dict) {
|
||||
updateEMA52Display(currentData);
|
||||
}
|
||||
if (window.ChanDeriv) ChanDeriv.sync({ overlay: false });
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('增量刷新失败,回退全量重建:', e);
|
||||
@@ -584,6 +589,7 @@ function refreshChart(data, options) {
|
||||
if (currentData && currentData.ema52_dict) {
|
||||
updateEMA52Display(currentData);
|
||||
}
|
||||
if (window.ChanDeriv) ChanDeriv.sync({ overlay: true });
|
||||
|
||||
}
|
||||
|
||||
@@ -597,14 +603,9 @@ function refreshChartOnly() {
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定主周期MACD背离显示开关
|
||||
$('#showMainMacdDiv').change(function() {
|
||||
refreshChartOnly();
|
||||
});
|
||||
|
||||
// 绑定次周期MACD背离显示开关
|
||||
$('#showElementMacdDiv').change(function() {
|
||||
refreshChartOnly();
|
||||
// 绑定主/次/次次周期笔面积、线段面积显示开关
|
||||
$('#showMainBiArea, #showMainSegArea, #showElementBiArea, #showElementSegArea, #showSubSubBiArea, #showSubSubSegArea').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 绑定分型类型显示开关(与笔一致:全量重建,避免增量路径标记未对齐)
|
||||
@@ -642,6 +643,12 @@ $('#toggleUOnElement').change(function() {
|
||||
});
|
||||
|
||||
// 买卖点显示开关
|
||||
$('#showOi').change(function() {
|
||||
if (window.ChanDeriv) ChanDeriv.loadOverlays();
|
||||
});
|
||||
$('#showDeriv').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
$('#showMainBsp').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
@@ -649,11 +656,6 @@ $('#showElementBsp').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 第四类买卖点(B4/S4)显示开关与过滤模式
|
||||
$('#showMainFastBsp, #showElementFastBsp, #showSubSubFastBsp, #fastBspFilterMode').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 在控制台输出当前显示状态
|
||||
console.log('当前显示状态:', {
|
||||
'showOriginalKline': $('#showOriginalKline').is(':checked'),
|
||||
|
||||
+103
-44
@@ -858,6 +858,38 @@
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.deriv-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
padding: 6px 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.deriv-bar .deriv-label {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.deriv-bar .deriv-chip {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #343a40;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
.deriv-bar .deriv-chip.up { color: #198754; }
|
||||
.deriv-bar .deriv-chip.down { color: #dc3545; }
|
||||
.deriv-bar .deriv-src {
|
||||
color: #868e96;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -935,21 +967,31 @@
|
||||
<!-- 添加K线周期切换 -->
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="klinePeriod" id="mainPeriodKline" checked>
|
||||
<label class="form-check-label" for="mainPeriodKline">主周期</label>
|
||||
<label class="form-check-label" for="mainPeriodKline">主</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="klinePeriod" id="elementPeriodKline">
|
||||
<label class="form-check-label" for="elementPeriodKline">小周期</label>
|
||||
<label class="form-check-label" for="elementPeriodKline">次</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="klinePeriod" id="subSubPeriodKline">
|
||||
<label class="form-check-label" for="subSubPeriodKline">次次周期</label>
|
||||
<label class="form-check-label" for="subSubPeriodKline">次次</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMacd" checked>
|
||||
<label class="form-check-label" for="showMacd">ChanMACD</label>
|
||||
<label class="form-check-label" for="showMacd">MACD</label>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1 p-0" onclick="showMacdConfig()" style="width:22px;height:22px;line-height:1;font-size:12px;" title="MACD参数设置">⚙</button>
|
||||
</div>
|
||||
<span id="showSentimentWrap" class="d-inline-flex align-items-center">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showOi" checked>
|
||||
<label class="form-check-label" for="showOi">OI</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showDeriv" checked>
|
||||
<label class="form-check-label" for="showDeriv">衍生品</label>
|
||||
</div>
|
||||
</span>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<label for="refreshInterval" class="form-label me-2 mb-0">自动刷新:</label>
|
||||
<select id="refreshInterval" class="form-select form-select-sm me-2" style="width: 80px;">
|
||||
@@ -977,7 +1019,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<label class="form-label me-0 mb-0">主周期:</label>
|
||||
<label class="form-label me-0 mb-0">主:</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<select id="timeframe" class="form-select form-select-sm me-2" style="width: 100px;">
|
||||
{% for value, label in timeframes.items() %}
|
||||
@@ -998,11 +1040,11 @@
|
||||
<label class="form-check-label" for="showMainZs">SEG中枢</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainBiZs">
|
||||
<input class="form-check-input" type="checkbox" id="showMainBiZs" checked>
|
||||
<label class="form-check-label" for="showMainBiZs">BI中枢</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showKlcFxType" checked>
|
||||
<input class="form-check-input" type="checkbox" id="showKlcFxType">
|
||||
<label class="form-check-label" for="showKlcFxType">KLC分型</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
@@ -1010,27 +1052,24 @@
|
||||
<label class="form-check-label" for="showMainTrend">Trend</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="toggleUOnMain">
|
||||
<input class="form-check-input" type="checkbox" id="toggleUOnMain" checked>
|
||||
<label class="form-check-label" for="toggleUOnMain">显示U</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainBiArea">
|
||||
<label class="form-check-label" for="showMainBiArea">笔面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainSegArea">
|
||||
<label class="form-check-label" for="showMainSegArea">线段面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainBsp">
|
||||
<label class="form-check-label" for="showMainBsp">买卖点</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainFastBsp">
|
||||
<label class="form-check-label" for="showMainFastBsp">第四类</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<select id="fastBspFilterMode" class="form-select form-select-sm" style="width: 130px;"
|
||||
title="深色为区间套(大级别分型同向)+中枢顺向推进都满足的信号,浅色为未通过过滤">
|
||||
<option value="all" selected>第四类:全部</option>
|
||||
<option value="filtered">第四类:仅过滤后</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<label class="form-label me-0 mb-0">次周期:</label>
|
||||
<label class="form-label me-0 mb-0">次:</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<select id="elementTimeframe" class="form-select form-select-sm me-2" style="width: 100px;">
|
||||
{% for value, label in timeframes.items() %}
|
||||
@@ -1066,17 +1105,21 @@
|
||||
<input class="form-check-input" type="checkbox" id="toggleUOnElement">
|
||||
<label class="form-check-label" for="toggleUOnElement">显示U</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showElementBiArea">
|
||||
<label class="form-check-label" for="showElementBiArea">笔面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showElementSegArea">
|
||||
<label class="form-check-label" for="showElementSegArea">线段面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showElementBsp">
|
||||
<label class="form-check-label" for="showElementBsp">买卖点</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showElementFastBsp">
|
||||
<label class="form-check-label" for="showElementFastBsp">第四类</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<label class="form-label me-0 mb-0">次次周期:</label>
|
||||
<label class="form-label me-0 mb-0">次次:</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<select id="subSubTimeframe" class="form-select form-select-sm me-2" style="width: 100px;">
|
||||
{% for value, label in timeframes.items() %}
|
||||
@@ -1113,12 +1156,16 @@
|
||||
<label class="form-check-label" for="toggleUOnSubSub">显示U</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubBsp">
|
||||
<label class="form-check-label" for="showSubSubBsp">买卖点</label>
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubBiArea">
|
||||
<label class="form-check-label" for="showSubSubBiArea">笔面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubFastBsp">
|
||||
<label class="form-check-label" for="showSubSubFastBsp">第四类</label>
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubSegArea">
|
||||
<label class="form-check-label" for="showSubSubSegArea">线段面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubBsp">
|
||||
<label class="form-check-label" for="showSubSubBsp">买卖点</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1126,6 +1173,17 @@
|
||||
</div>
|
||||
|
||||
<div id="loadingIndicator" style="display:none;"></div>
|
||||
<div id="derivBar" class="deriv-bar">
|
||||
<span class="deriv-label">资金面</span>
|
||||
<span class="deriv-chip" id="derivOi">OI —</span>
|
||||
<span class="deriv-chip" id="derivOiChg">Δ —</span>
|
||||
<span class="deriv-chip" id="derivFunding">费率 —</span>
|
||||
<span class="deriv-chip" id="derivBasis">基差 —</span>
|
||||
<span class="deriv-chip" id="derivTaker">买卖比 —</span>
|
||||
<span class="deriv-chip" id="derivLs">多空 —</span>
|
||||
<span class="deriv-chip" id="derivTop">大户 —</span>
|
||||
<span class="deriv-src" id="derivSrc"></span>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<div id="tradingview_chart"></div>
|
||||
@@ -1278,24 +1336,25 @@
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}?v=20260910c"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/deriv_ui.js') }}?v=20260911i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/state.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260810e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260911j"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}?v=20260810e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260810e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_lifecycle.js') }}?v=20260810e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260810e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260810d"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260827a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260810a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260809z"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260827a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260912a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_lifecycle.js') }}?v=20260910c"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260911l"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260911n"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260910a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260911j"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260910c"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260910c"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260910c"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260911j"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260912a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260901d"></script>
|
||||
|
||||
<!-- 均线配置弹窗 -->
|
||||
<div id="maConfigModal" class="ma-config-modal">
|
||||
@@ -1479,7 +1538,7 @@
|
||||
<!-- MACD参数配置弹窗 -->
|
||||
<div id="macdConfigModal" class="macd-config-modal">
|
||||
<div class="macd-config-content">
|
||||
<div class="macd-config-title">ChanMACD 参数设置</div>
|
||||
<div class="macd-config-title">MACD 参数设置</div>
|
||||
<div class="macd-config-form">
|
||||
<div class="macd-config-group">
|
||||
<label class="macd-config-label">快线周期 (Fast)</label>
|
||||
|
||||
@@ -105,6 +105,32 @@ def test_analyze_chan_keys_on_fixture():
|
||||
assert k in result["chan_macd"]
|
||||
|
||||
|
||||
def test_bi_and_seg_area_div_computed():
|
||||
from services.runtime import add_indicators, analyze_chan
|
||||
|
||||
df = add_indicators(make_ohlcv(400))
|
||||
result = analyze_chan(df, symbol="TEST/USDT:USDT", timeframe="5m")
|
||||
bis = result["bi_list"]
|
||||
segs = result["seg_list"]
|
||||
assert bis, "fixture should produce bi"
|
||||
assert all(hasattr(bi, "macd_div") for bi in bis)
|
||||
assert all(hasattr(seg, "macd_div") for seg in segs)
|
||||
assert all(hasattr(bi, "macd_hist") for bi in bis)
|
||||
assert all(hasattr(seg, "macd_hist") for seg in segs)
|
||||
same_dir_bis = [bi for bi in bis if getattr(bi, "pre", None) and getattr(bi.pre, "pre", None)]
|
||||
if same_dir_bis:
|
||||
bi = same_dir_bis[-1]
|
||||
prev = bi.pre.pre
|
||||
if prev.macd_hist:
|
||||
assert abs(bi.macd_div - (bi.macd_hist / prev.macd_hist)) < 1e-9
|
||||
same_dir_segs = [seg for seg in segs if getattr(seg, "pre", None) and getattr(seg.pre, "pre", None)]
|
||||
if same_dir_segs:
|
||||
seg = same_dir_segs[-1]
|
||||
prev = seg.pre.pre
|
||||
if prev.macd_hist:
|
||||
assert abs(seg.macd_div - (seg.macd_hist / prev.macd_hist)) < 1e-9
|
||||
|
||||
|
||||
def test_serialize_chan_macd_shape():
|
||||
from pytz import timezone
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""资金面中转:Web 只打 data_provider,字段原样回给前端。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
WEB_ROOT = Path(__file__).resolve().parents[1]
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(WEB_ROOT))
|
||||
|
||||
|
||||
SNAPSHOT = {
|
||||
"exchange": "bitget",
|
||||
"symbol": "BTC/USDT:USDT",
|
||||
"timestamp": 1789039236740,
|
||||
"datetime": "2026-09-10T11:20:36.740000Z",
|
||||
"funding_rate": 0.0001,
|
||||
"open_interest": 36207.21,
|
||||
"oi_change_pct": -0.01,
|
||||
"basis": -0.03,
|
||||
}
|
||||
|
||||
OI_HIST = {
|
||||
"metric": "open_interest_history",
|
||||
"symbol": "BTC/USDT:USDT",
|
||||
"period": "15m",
|
||||
"count": 1,
|
||||
"data": [
|
||||
{
|
||||
"timestamp": 1789038900000,
|
||||
"datetime": "2026-09-10T11:15:00Z",
|
||||
"open_interest_amount": 106162.005,
|
||||
"open_interest_value": 8269553076.678,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def test_derivatives_proxy_passthrough(client):
|
||||
with patch("api.provider.fetch_derivatives", return_value=SNAPSHOT) as mock_fetch:
|
||||
resp = client.get("/api/derivatives?symbol=BTC/USDT:USDT")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["funding_rate"] == 0.0001
|
||||
assert body["open_interest"] == 36207.21
|
||||
assert body["oi_change_pct"] == -0.01
|
||||
assert body["basis"] == -0.03
|
||||
mock_fetch.assert_called_once()
|
||||
assert mock_fetch.call_args[0][0] == "BTC/USDT:USDT"
|
||||
|
||||
|
||||
def test_sentiment_metrics_proxy_passthrough(client):
|
||||
with patch("api.provider.fetch_sentiment_metrics", return_value=OI_HIST) as mock_fetch:
|
||||
resp = client.get(
|
||||
"/api/sentiment/metrics?metric=open_interest_history&symbol=BTC/USDT:USDT&limit=1"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["metric"] == "open_interest_history"
|
||||
assert body["data"][0]["open_interest_amount"] == 106162.005
|
||||
mock_fetch.assert_called_once()
|
||||
assert mock_fetch.call_args[0][0] == "open_interest_history"
|
||||
|
||||
|
||||
def test_sentiment_metrics_requires_metric(client):
|
||||
resp = client.get("/api/sentiment/metrics?symbol=BTC/USDT:USDT")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_sentiment_latest_proxy_passthrough(client):
|
||||
latest = {
|
||||
"symbol": "BTC/USDT:USDT",
|
||||
"data": {
|
||||
"taker_buy_sell_ratio": {"buy_sell_ratio": 1.36},
|
||||
"long_short_account_ratio": {"long_short_ratio": 1.5},
|
||||
"top_long_short_position_ratio": {"long_short_ratio": 2.26},
|
||||
},
|
||||
}
|
||||
with patch("api.provider.fetch_sentiment_latest", return_value=latest) as mock_fetch:
|
||||
resp = client.get("/api/sentiment/latest?symbol=BTC/USDT:USDT")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["data"]["taker_buy_sell_ratio"]["buy_sell_ratio"] == 1.36
|
||||
mock_fetch.assert_called_once()
|
||||
Reference in New Issue
Block a user