Author SHA1 Message Date
jack be442783a1 Merge branch 'chan' of ssh://git.jackyu66.com:2222/jack/chan into chan 2026-08-28 16:07:28 +08:00
jackandCursor f11c6507b0 实盘执行改走 Bitget v2 REST,止损挂到服务端
丢掉 Hummingbot 的 PositionExecutor。它的连接器只暴露 LIMIT / LIMIT_MAKER /
MARKET,没有触发单,于是 control_stop_loss() 只能本地盯价、触发时才发市价单
——**进程一死仓位就是裸的**。而交易所本身支持 place-order 带
presetStopLossPrice,下单时就把止损挂到服务端。绕过连接器不是图省事,是为了
消掉一整类故障。顺带 TripleBarrierConfig 只有单级止盈,装不下两级,自己写更短。

## 三条出场腿各自挂在哪

    止损   交易所侧(presetStopLossPrice,随入场单一起到)→ 进程死了仍在
    止盈   交易所侧(post_only reduce-only 限价)        → 进程死了仍在
    超时   本进程,48 分钟到点市价平

所以进程死掉只会让持仓超过 48 根,不会变成裸仓,退化是良性的。

## 止盈不能用 presetStopSurplusPrice

它触发后按市价执行,而成本模型里止盈是 maker——那 60% 的出场不吃滑点、按
maker 费率计(LEG_IS_TAKER)。用 preset 会让这部分变成 taker,预算就不成立。
所以止盈单独挂 post_only + reduceOnly 限价单。止损反过来必须市价:stop-limit
在急跌里可能不成交,损失远大于省下的费。

## clientOid 是交易所级幂等,但要小心两个坑

信号键形如 SOL:1787904388411:+1,`:` 和 `+` 未必被接受,带过去直接拒单——而
拒单发生在入场腿上,等于这笔信号静默漏掉。

清洗时不能简单把非字母数字换成下划线:那样 `+1` 和 `-1` 都变成 `_1`,同一根上
的多空信号得到相同 oid,第二笔被当重复拒掉。方向显式编码为 L/S。

用 clientOid 而非只靠本地去重,是因为「已发出但没收到回复」这种情况本地判不了,
重试就会开两次仓。

## 空跑要走完 open_position

第一版在 on_signal 里 `if dry: return`,结果数量取整、价位对齐 tick、请求体
构造全都没被验到。现在空跑走完整条路径,不下真单由 Bitget(dry=True) 负责。

实测两笔:SOL 多头 entry 106.706 / ATR 11bp → 止损 106.471、止盈 107.058 与
107.645;BTC 空头 → 止损在上方 79747.5、止盈在下方。半仓精确一半(SOL 2.3+2.3、
BTC 0.0031+0.0031)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:07:28 +08:00
jackyu66gitandCursor bfdb2f2e2a 引擎四处「算了没人要的东西」,append_bar 13.9ms → 6.6ms
服务端把增量上线后回传两个热点:add_indicators 为加一根重算全表(占 34%)、
cal_bi_list 整表重扫(51%)。顺着查下来四处都不是算法慢,是算了没人读的结果。

1. cal_trend 挂到 lean 下。它不是笔的依赖(bi.py:221 在它自己的循环里读自身
   序列状态),服务端 verify_incr_parity 三币 1800 根已对拍定论。web 走非
   lean,klc_trend 图层不受影响。

2. check_fx_pattern 删掉拼完就丢的字符串。它把 klu.to_string() 拼成 p 只为
   一行注释掉的 print——2000 根上近 3 万次 f-string 加 6 万次 enum 格式化,
   而且在 cal_bi_list 内层。klu.pattern 只被 cal_klu_pattern 自己的双K/三K
   判定读,不出模块不进 web,所以整个调用在 lean 下也跳过。

3. ChanBI.add_klc 去二次方。去重原本线性扫 klc_list,且每加一根就把整笔所有
   KLU 的 macdhist 重累一遍,往一笔加 k 根是 O(k²)。改成下标集合加
   macd_hist/macd_div 惰性求值。这两个值只有背驰判定(bsp.py)读,lean 下
   bsp 根本不算。

4. add_indicators 批量挂列。2001 行上 TA 计算合计只有 2.5ms,而 30 多次
   df['x']= 要 3.6ms——开销大头是 BlockManager 逐列插入不是计算,改为一次
   concat。cal_volume_ratio 里为算一列 rolling 而 copy() 整张 40 列表,一并去掉。

实测(本机,2001 根窗口。服务端基线 21.8ms 是另一台机器,别直接比绝对值):
  append_bar        13.9 → 6.6ms
  └ rebuild_bi_zs    8.7 → 2.8ms
  └ add_indicators   4.4 → 3.5ms
  TF_DF lean        49.8 → 32.9ms
  TF_DF full        72.7 → 64.9ms

对拍用 git worktree 检出改动前的提交,同一份 BTC 1m 4000 根跑 38 项指纹:
full 模式 19 项全部一致(web 那条路没动);lean 模式差 2 项,正是设计要它差
的 klc.trend 和 klu.pattern,而 lean 下 bi/zs/seg/bsp/dataframe 全部一致——
这就是「这两个字段没人读」的实测证据:打空它们,下游一位不变。

瓶颈已经换位置了。新增 probe_inner.py 拆 inner_ms 分档:本机 TF_DF 两条腿占
70%、build_htf_zones 13%、htf_fx_timeline 6%,而服务端报的是 chan 构建 22ms /
信号链 86ms,机器差解释不了这个四倍差距。曾怀疑是 payload 反序列化,实测
_rebuild 只有 1.0ms,假设不成立。两边跑同一探针对分档表才能定位。

HANDOFF 顺带修掉一处 5.6 重号(增量落地那节改为 5.7,本节挂 5.71)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:01:26 +08:00
jackyu66gitandCursor 06928d1d5f 开仓前那段:量要小、势要有但不能过头,换掉 vr60 那个开关
用户提出看开仓之前的量与趋势方向。这是第三个位置——step50 管信号根、
step52 管持仓中,这里管入场前。step48 采集端补 vpre10/vpre30/mom10/mom60。

先纠正一个错的心智模型(我和用户都以为的):B4/S4 不是回抽后进场。mom10
中位 +3.47 ATR,为负的只占 6%。信号触发时价格在前 10 根里已经顺着你的方向
走了三个多 ATR,2 ATR 止损是架在一段已经走完的行情后面。这也解释了 step50:
信号根是突破根,放量 = 追在最后一棒上。

① 入场前的量单调,越小越好(样本外毛R,Q1→Q4):1.474 / 1.266 / 1.156 /
0.573。控 vr60 后仍成立(低量层 −0.284、高量层 −0.541),与 vr60 相关只有
+0.301,不是同一件事换个说法。

② 入场前的动量是驼峰形,不是单调(mom60 样本外毛R,Q1→Q4):1.252 /
1.473 / 1.141 / 0.603。势要有——完全没动过的 Q1 也不如 Q2;但不能过头——
Q4 在发现期余量只剩 1.44bp,等于不能做。

驼峰形意味着中位数二分法会把它测没:控制表里 mom10 的毛R差是 +0.044,
看着无效,那是二分把 Q1+Q2 和 Q3+Q4 各自平均了。对非单调因子不要用中位数
分层做检验。

③ 砍 mom60≥7 全面优于 §3.39 定的砍 vr60≥4(发现期):

  等权          保留 100%  盈亏平衡 13.5bp  R夏普 0.325  回撤 16.8  总R 596.6
  砍 vr60≥4     保留  69%  盈亏平衡 15.6bp  R夏普 0.411  回撤 10.8  总R 514.9
  砍 mom60≥7    保留  78%  盈亏平衡 17.5bp  R夏普 0.476  回撤  7.7  总R 657.7

每一项都赢,还多留 9 个点的笔数。更要紧的是总R 比不砍还高——被砍掉那 22%
期望为负,砍掉不是花钱买稳健,是纯赚。样本外同向。所以这个开关不需要等
影子测量:它在 0~20bp 每一档都不输等权。

附滑点决策表:5~12bp 区间砍 mom60≥7 通吃,只有 ≥15bp 才该上「三个都砍」,
而那时策略本身已在生死线上。

阈值 7 和 1.5 是贴着 Q4 边界取的整数,不是搜出来的,但也不是完全无关于数据
(看过分位表才取的整),上线前应确认阈值附近没有断崖敏感。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:55:49 +08:00
jackyu66gitandCursor 431e776905 持仓中放量不是出场信号,恰恰是最好那批单子的标记
用户问:既然 step50 证明入场根放量是接盘,那持仓中出现放量根是不是也说明
这一波走完了、该直接平掉?测下来方向是反的,且比入场那条还干净。

规则:持仓期间任一根 vr60 ≥ 阈值就收盘市价平掉(taker)。同根内优先级
止损(盘中)> 目标(盘中)> 放量平仓(收盘)。10 币 3941 笔:

  基线(不看量)  毛R 1.068  R夏普 0.627  PF 3.80  余量 18.45bp  均持仓 30.0
  vr60≥3 就平    毛R 0.617  R夏普 0.459  PF 2.81  余量  7.81bp  均持仓 11.1
  vr60≥5 就平    毛R 0.858  R夏普 0.568  PF 3.37  余量 12.85bp  均持仓 19.8
  vr60≥8 就平    毛R 1.009  R夏普 0.617  PF 3.69  余量 16.68bp  均持仓 26.3

阈值越高、触发越少就越接近基线——这条曲线的最优点是「永不触发」,规则纯扣分。
「浮盈才平」的变体把胜率抬到 74.4%(基线 71.7%)而毛R 掉到 0.655,是过早
止盈的教科书特征:胜率上升、期望下降。

根因:vr60≥5 触发的 1769 笔若不平,止盈率 45.7%、止损率 16.6%,而全体基线
是 30.1% / 33.1%。持仓中的放量根标记的是最好的那批单子,平掉每笔让出 +0.484R。
幸存者偏差已控——按基线持仓 ≥K 根分层后 5/5 档同向,放量组止盈率约为无量组
两倍(K=5 时 41.2% vs 19.8%)。

同一个事件入场为负、持仓为正,区别只在站在它的哪一边:入场那根放量你是买方,
持仓中那根是资金来接你的货。用户「有资金的趋势才是好趋势」的直觉成立,
作用点在持仓期而非入场点。

自带逐根模拟器不走 walk_exits,所以先与它对拍基线(毛收益差 <1e-12、出场
原因零分歧),10/10 币通过才往下算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:55:49 +08:00
jackandCursor 360e4e1c41 自动化实盘执行器:信号总线 + 两个半仓分解 + 四条硬约束
## 架构:影子发信号,实盘执行,两个进程

不让实盘自己算信号。前两个理由是资源与隔离(2 核上再来一份十币计算会把清空
从 247ms 推到 500ms+;实盘崩溃不该影响正在采的数据集),第三个最要紧:
**实盘交易的必须是影子测量的那一个信号**。各算一份会让两边悄悄分叉,之后就
没法把实盘实际成交和影子测的滑点曲线对照——而那个对照是整件事的目的。

总线用 append-only JSONL + fsync:崩溃安全,且「影子在跑、实盘没在跑」会表现
为信号在攒着,而不是静默丢弃。

## 出场结构精确分解成两个半仓

TripleBarrierConfig 只有单级止盈,装不下 3ATR 减半 + 8ATR 目标。但已核实
exit_model.py:151 的 runner_stops 是从**入场价**算的(ret = (entry-low)/a),
且 RUNNER_STOP == SL == 2.0,所以两半共用同一个不动的止损,可精确分解为:

    半仓 A  市价入场 · TP 3ATR · SL 2ATR · 48min
    半仓 B  市价入场 · TP 8ATR · SL 2ATR · 48min

止损先到则两半都在 -2ATR 出场;3ATR 先到则 A 出场、B 继续且止损仍在 2ATR。
与回测逐情形一致。assert_decomposable() 在启动时挡住 RUNNER_STOP != SL 的
改动,否则实盘会跑另一个收益结构且不报错。

## 硬约束是这个文件的重点

一笔止损只亏约 1 USDT,所以「亏损可控」对单笔成立。但三类故障的代价**不随
仓位缩小**,必须显式封住:失控下单(MAX_OPEN=3 / MAX_DAY=15)、亏损累积
(MAX_DAY_LOSS=20)、裸仓(重启对账)。状态落盘且原子替换——不落盘的话反复
重启就等于反复重置日上限,而失控下单恰好常伴随反复重启。

已验:幂等去重、并发上限、日开仓上限、日亏损上限、跨日归零且保留 done 键、
重启后计数不清零、状态文件损坏时不抛异常。

## 重启对账选择平掉而非接管

崩溃重启后交易所可能还有仓位,而 executor 全没了,那些仓位没有任何止损在盯。
接管需要重建入场价/ATR/剩余半仓状态/已过根数,任一项猜错就让出场结构变成
另一个东西;平掉的代价只是一笔小额亏损,且行为确定。

## 已知风险:止损在机器人侧

Bitget 连接器只支持 LIMIT / LIMIT_MAKER / MARKET,无触发单。
PositionExecutor.control_stop_loss() 是本地盯价、触发时才发市价单,所以进程
一死仓位就是裸的。10x 下强平需逆向 10%(约 100 个 ATR),48 分钟内极不可能,
单次代价仍封在保证金内。下一步用 Bitget 服务端 TP/SL 计划单做兜底。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:52:03 +08:00
jackandCursor f716ddd149 推送带杠杆与保证金,名义额默认抬到 500
杠杆只影响占用保证金,**不改**名义敞口、手续费、滑点、盈亏绝对值——费与滑点
都按名义额收。所以「用杠杆所以可以很小额」这个方向要说清:杠杆省的是本金
占用,不是成本。

它真正的用处是让「抬名义额」几乎免费,而抬名义额正好压掉步长取整。实测
取整到步长偶数倍后最差币的名义偏差:100U → 6.7%(SOL)、500U → 1.8%、
1000U → 0.6%。所以默认名义改 500、杠杆 10x,每笔占 50 USDT 保证金。

止损在 2 ATR ≈ 0.2%,而 10x 的强平约需逆向 10% = 100 个 ATR,差 50 倍。
这个止损紧到杠杆几乎不引入强平风险,所以这里没有「杠杆换风险」的取舍。
仍建议逐仓,让每笔最大损失被保证金封住。

推送里加上占用保证金与「触止损亏多少 USDT / 占保证金百分之几」,手工执行时
不必自己换算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:44:39 +08:00
jackandCursor c57adc8cb0 推送按合约规则取整,减半腿精确一半
100 USDT 的仓位在 Bitget 上不是最小下单量的问题(minTradeUSDT=5、
minTradeNum 极小,10U 都能下),是**步长取整**的问题:

  SOL  步长 0.1 币 ≈ 10.7U → 50U 腿只能取 0.4 币 = 42.7U,偏 14.6%
  LINK 步长 1 币 ≈ 11.7U   → 4 币 = 46.8U,偏 6.4%
  BTC  步长 0.0001 ≈ 8.0U  → 0.0006 = 47.8U,偏 4.4%

减半腿变成全仓的 43% 而非 50%,剩下 57% 暴露在 8ATR 目标上,而回测的收益
结构假设 50/50。小额下这不影响机制验证,但会让 P&L 读不出回测那个结构。

解法是入场量取到**步长的偶数倍**,这样一半天然落在步长上。实测十个币减半腿
全部精确 50%,名义额落在 93.6~106.7 USDT——小额实盘无所谓。

同时把价位对齐到 tick(priceEndStep × 10^-pricePlace),否则限价单会被拒。
规则拉一次缓存;拉不到就退化为不取整并打日志,不阻断推送。

费率一事已核实无需改动:预算假设的挂牌 taker 0.040% / maker 0.016% 正是
Bitget VIP2 的官方档(返 50% 后 2.0 / 0.8bp)。合约接口返的 6bp/2bp 是 VIP0
基础档,不适用。8bp 门控阈值不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:42:17 +08:00
jackandCursor 97457b0518 过滤网信号推 Telegram,供手工小额实盘
自动执行链一行都还没写(下单/持仓状态/跨重启持久化/对账/熔断),而过全部
滤网的信号只有约 5.3 笔/天——低到人手能接。先手工跑一批,就能在写自动化
**之前**拿到真实费率档、真实成交价、真实出场行为,让执行链的每个假设都有
实测对照,而不是写完再发现出场模型不对。

推送内容按手工执行需要给全:参考成交价(回测口径的次根开盘)、按 2/3/8 ATR
换算的绝对价位、下单数量、该币滑点预算,以及一句「偏离超过预算就不值得做」。
剩余半仓止损保持 2ATR 不移成本,这是回测参数,移了就不是同一个收益结构。

时效是这条路最大的风险,所以起点取 kline_ts 而不是信号产生时刻——参考价就是
在 kline_ts 那一刻存在的,从信号时刻起算会漏掉数据延迟加计算那 0.5~1.5s,而
那段不可压缩。超过 TG_STALE_S 直接标记已失效,不让人自己判断:宁可漏做,不
要在偏离预算之外入场。

三条防线:lag 退化时不推(与「停开新仓」同一条规则,不能只在自动化里执行);
按 (币, K线时刻, 方向) 去重,避免补根或池重建重放导致开两次仓;无预算的币
(如 TRX)不推。推送任何失败只打日志,不连坐采集——已验假 token 下降级为
HTTP 401 日志而非抛异常。

凭据走 tg.env(已 gitignore),给了 tg.env.example 说明怎么拿 token 和 chat id。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:35:17 +08:00
jackandCursor 0745e73b4f 判定要知道增量开没开,否则会推荐已经在做的事
上线 9 小时后实测踩到:清空 326ms 触发「币数 > 核数」那一支,于是继续输出
「唯一出路是走增量」——而增量已经生效。同一个分支在增量前后给同一个答案,
等于没有判定。

现在分开:增量未开就指向开增量;已开则指出单币 102ms 里 chan 构建只剩约
22ms,其余是 build_htf_zones / find_fast_bsp3 / attach_htf_context,要继续
压得改这三个或减币。顺带把「宽裕」阈值从 300 提到 400ms——清空 326ms 在
数据到达中位 347~558ms 面前不是瓶颈。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:24:31 +08:00
jackandCursor 545fcc7c77 补验多 worker 交错下的一次追加多根
原对拍每根都只追加 1 根,覆盖不到多 worker 交错的实际路径:
ProcessPoolExecutor 不保证同一个币落到同一个 worker,所以每个 worker 隔 nw
根才再见到这个币,一次要补 nw 根。「补 nw 根等价于连续追加 nw 次」是推理,
没实测过。

--interleave N 用 N 份独立缓存轮流接同一个币。BTC/SOL 各 300 根、2 份缓存,
逐字段零分歧,各缓存末窗 2299 根。

顺带记清亲和性的性质:缓存是 worker 进程内的 dict、键含 symbol,不存在
「worker A 的状态被 B 读到」或「拿到别的币的状态」。亲和性影响的是内存
(每个 worker 最终缓存全部币)与补根次数,不影响正确性。实测内存
597→598MiB,代价在噪声里。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 06:24:51 +08:00
jackandCursor 34a8f37b39 HANDOFF:更正 cal_trend 依赖那句,补 §5.6 增量落地
原先写「cal_trend 不能跳过——bi.py:221 读 klc.trend,笔的计算依赖它」。这条
不成立:221 行在 cal_trend 自己的循环里,读的是它自身的序列状态,不是
cal_bi_list 的依赖。1,800 根对拍定论——增量追加的 klc 其 trend 恒为 UNKNOWN,
与批量构建(trend 有值)逐字段相同。这处偏差是读代码读出来的,对拍一次就
定论了,同类判断优先用对拍。

另标注「append_bar 32ms 对 800ms 有 25 倍余量」的口径问题:单币构建不是信号
总延迟,多币要串行清空,且 800ms 哨兵测的是数据到达、不与计算共享预算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 06:19:00 +08:00
jackandCursor 0a0fd2f682 影子信号走增量:清空十币 560ms → 247ms
compute() 每根新建 TF_DF 换成按 (symbol, timeframe) 缓存的流式对象。worker
进程被复用,所以缓存跨根存活;2 worker 轮流拿 10 币,每个 worker 最终缓存
全部 20 条流,实测内存开销落在噪声里(597→598MiB)。

## 前提先验,否则整个改动建立在沙子上

init_stream/append_bar **没有 trim**,dataframe 靠 pd.concat 无界增长。所以
增量必然让窗口每根 +1,只能周期性重建拉回,两次重建之间窗口是 [W, W+500]
而非恒定 W。于是必须先证明 compute() 输出对窗口长度不敏感——否则增量等于
静默换掉一批信号,不报错不崩。

verify_window_sens.py:三币 75 个信号窗口,+200/+500/+1000 三档全部逐字段
一致。step39 说的是「命中率在 2000 根饱和」,饱和不等于不变,这是两回事。

## 对拍

verify_incr_parity.py:三币 1,800 根、21 个命中、各跨 1 次重建边界,逐字段
零分歧。不能引用 HANDOFF §5.5——那验的是 bsp_list 那条链的整体哈希,而这里
是 find_fast_bsp3 那条链,且流式对象跨根复用,状态污染只会让信号悄悄换一批。

对拍顺带定论一件读代码定不了的事:cal_bi_list **不依赖** klc.trend。
init_stream/append_bar 从不调 cal_trend(它只在 get_klc_list 里),所以追加
出来的 klc 其 trend 恒为 UNKNOWN,而批量构建的有值;两者结果逐字段相同。
HANDOFF §5.5 那句「bi.py:221 读 klc.trend,笔的计算依赖它」不成立——221 行
在 cal_trend 自己的循环里,读的是它自身的序列状态。

## 重建不走 init_stream

init_stream 是逐行 dataframe.iloc[idx],正是引擎提速刚修掉的反模式:2001 根
要 238.5ms,而批量 lean 只 74.3ms,慢 3.2 倍。第一版用它重建,10 个币启动时
各来一次,清空反而涨到 1686ms。改用 TF_DF(df, lean=True) 重建,append_bar
靠 _ensure_stream_state 就能接上。

## 实测

append_bar 21.8ms vs 批量 lean 重建 77.7ms = 3.56x,与研究侧测的 3.7x 一致。
拆解:add_indicators 全表 7.5ms(34%,为加一根重算 2001 行)+ cal_bi_list
整表重扫 11.1ms(51%)+ concat 1.4ms。这两项都在引擎侧,值得反馈。

十币 / 2 核:清空 560→247ms,排队 92→10ms,纯计算 219→108ms。判定从
「加 worker 无用,唯一出路是增量」变成「宽裕,无需优化」。

注意 inner 108ms 里 chan 构建只占约 22ms,其余是 build_htf_zones /
find_fast_bsp3 / attach_htf_context。**瓶颈已不在 chan 构建**,再压增量收益
有限。

stream_bars 落到 latency CSV:恒等于 2001 说明缺口判定在每根都回退重建、
增量静默失效,这一点从耗时上看不出是哪一环。实测窗口稳定长大。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 06:17:47 +08:00
jack c8a0062707 Merge branch 'chan' of ssh://git.jackyu66.com:2222/jack/chan into chan 2026-08-28 05:19:15 +08:00
jackandCursor f5f456c523 计算判定改看「清空全部币」,并挡掉空窗口
判定原先比逐根的 queue_ms 与 inner_ms,结构上错了两处,十币下直接指反:

判据错。要紧的是一个收盘时刻清空所有币要多久,不是单币的 q 或 i。币同一秒
收盘,币数超 worker 数时后面的币串行等待,这笔代价不出现在任何单根的 q 或 i
里。现在按 kline_ts 聚合取各币最大 compute_ms,落到 clear_hist。

出路错。「排队为主 → 加核」只在还有空闲核时成立。worker 已等于核数时加
worker 不增吞吐,只把等待从 queue 挪到 inner。十币实测正是如此:inner 被
争抢从 144 抬到 192ms 反超 queue 135ms,于是判定落到「量级已低、无需优化」
——而此时最后一个币已在 1376ms。现在币数超核数就直接指向增量路径。

顺带修一个瞬时故障:WS 重连瞬间 feed 的 deque 可能为空,空窗口放行会让
worker 抛「DataFrame for 1m is empty」,白占一个计算槽(币数超核数时会推迟
后面所有币),而报错文本还会让人以为是缺历史数据。加 MIN_BARS 守卫。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:19:14 +08:00
jackyu66gitandCursor d4fbe9d905 量因子该做成开关不是权重;前序因子不该进仓位
把 step49/50 两个分层结论做成仓位因子做组合层面评估,结果与分层表给人的印象
不一致,两个因子的命运相反。

方法上设了三道约束——分层结论转仓位规则最容易在这三处翻车:

  1 阈值取整数(vr60 切 1/2/4)、权重取整数比,一个参数都不搜。观测到的四分位
    边界在不同时段并不一致(样本外 0.63/1.26/2.28/5.34、发现期 0.62/1.48/
    3.11/7.97),用样本分位数等于在同一批数据上既发现又调参
  2 权重归一化到均值 1,各方案投出去的平均资金相同,均R 才可比
  3 判据是四项一起看:均R / R夏普 / 最大回撤 / 峰值加权并发。只看均值必然误判

全样本 3941 笔,假定滑点 5bp/taker 腿:

                     盈亏平衡滑点  R夏普  回撤R  峰值并发  总R/峰值并发
  等权(现状)           16.7bp   0.420  17.3    7.00      371.4
  仅量因子(加权)       18.2bp   0.442  12.7    7.98      367.4
  仅前序因子             17.0bp   0.421  19.2    8.46      312.6
  硬砍高量档(vr60≥4)   18.6bp   0.496  11.2    6.00      389.3
  硬砍 + 前序权重        19.0bp   0.500  11.0    7.19      332.1

① 前序因子不能做仓位。+6bp 余量在 5bp 滑点假设下只值约 0.011R,而这批信号
按定义 100% 发生在别的币已有仓位时——加权就是在敞口最集中的时刻加杠杆。
盈亏平衡只买到 +0.3bp,峰值并发 7.00→8.46、回撤 17.3→19.2,按峰值保证金归一
后是净负的(371→313)。§3.31 里「可用于加仓位权重」那句作废。出路可能是改用
放宽 ATR 门控兑现(多做几笔而非每笔做大),未测。

② 量因子「不做」优于「少做」。硬砍付笔数 −22%、总R −10%,换回撤 −35% 和峰值
并发 7→6。并发这一项单独就值:§3.31 已把峰值敞口列为扩币的前置约束。

③ 优势的形态是削尾不是抬均值。均R 差在各滑点档几乎恒定(0.085→0.082),但
基数在塌,所以相对优势随成本上升放大:15bp 处总R/回撤 2.57→11.86,靠的是回撤
从 150.5 掉到 60.3。这两个因子买的是尾部风险,不是收益。

可操作口径要用发现期:盈亏平衡滑点样本外 18.2bp、发现期只有 13.5bp(硬砍后
15.6bp),差距就是 2026 的 ATR 压缩。13.5 与 shadow_budget 的 15.19bp 同量级,
互为印证。影子测量要对标 13.5 / 15.6,不是 18.6。

开关先不上:只是 live 信号路径加一行,随时能加。等实测滑点出来再定——远低于
13.5bp 则等权就够,贴着 13.5bp 则这 2.1bp 就是生死线。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:08:34 +08:00
jackyu66gitandCursor ef584b8d49 成交量假设方向是反的:高量入场毛R 0.794,低量 1.421
用户假设「有资金的趋势才是好趋势」,预期开仓根成交量越大越好。成交量在信号根
收盘时可知,符合 step48 立的「只用开仓时已知信息」纪律,是合法的可交易切法。
10 币 × 80 万根、实盘口径 3941 笔,结论与假设相反。

按 vr60(当根量 / 前 60 根均量)四分位:

  量最低(中位 0.63)  毛R 1.421  余量 27.18bp   ← 样本外
  量最高(中位 5.34)  毛R 0.794  余量 13.47bp

单调递减,且样本内外、两套量比基准(前 10 根 / 前 60 根)全部同向。稳健性达到
step49 那条的标准:ATR 四分位 4/4 同向、逐时段 7/7 同向,不是 ATR 换脸。

机制在出场结构里,伤害全在止损命中率:

  量最低  止盈 33.5%  止损 22.5%  超时 44.0%  赢时均R 1.830  亏时均R −1.098
  量最高  止盈 26.1%  止损 45.3%  超时 28.6%  赢时均R 1.651  亏时均R −1.106

亏损幅度四档全是 −1.10(止损就是止损),赢时均R 只降 10%,止损率翻倍是全部
损失来源。这里有个判别点:若只是「2 ATR 止损相对突然放大的波动太窄」的尺度
错配,超时单应按原比例分流进止盈和止损两侧;实际是超时(−15.4pp)和止盈
(−7.4pp)一起流进止损(+22.8pp)。方向本身在变差,不只是止损太窄。

为什么直觉会反:B4/S4 在突破根上进场。大量根意味着这一冲已经由别人的资金
完成,你在它的收盘价接手。「有资金」要能获利必须在资金到达之前进场,不是同时。

与「有前序」是两件独立的事(有前序组 vr10 中位 1.76 vs 无前序 1.46),可叠加:
低量 × 有前序 253 笔,毛R 1.398、余量 31.00bp,是目前见过最宽的执行容忍度。

step48 的采集加 vr10/vr60 两列。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:08:03 +08:00
jack 2b9a837a58 Merge branch 'chan' of ssh://git.jackyu66.com:2222/jack/chan into chan 2026-08-28 04:50:16 +08:00
jackandCursor ef2a85dd8c 币池可配,实测十币的排队代价;标出漂移的 tick 地板
加 --syms(start.sh 传 SYMS),默认仍是三币。TRX 不进十币池:实盘口径 208 天
只有 5 笔,ATR 门控几乎全刷掉。

十币实测(对比三币):

  queue_ms        2 → 169ms(P90 519ms,每刻最大排队中位 533ms)
  inner_ms      144 → 196ms(CPU 争抢也拖慢了纯计算)
  lag_signal_ms 621 → 958ms;同一收盘时刻最后算完的币中位 1376ms

排队从可忽略变成主项,与「10 币 × 196ms ÷ 2 核 ≈ 640ms 突发」吻合。这也是
之前「加核没用」那个结论唯一会翻转的场景:CPU 占用率只有约 3%,问题纯粹是
所有币同一秒收盘的突发,加核压的是并行度而非单币耗时。

但 800ms 哨兵不受影响——它喂的是 t_data − kline_ts(数据腿),十币下逐币
154~532ms 全在线内。加币不碰那道闸。

另外发现一个会被静默误读的东西:ADA/AVAX/DOGE/LINK/LTC 的漂移中位精确等于
半个 tick 且在四个延迟点上完全相同。那不是漂移,是中价的最小变动量——ADA 半
tick 就有 2.34bp。拿这个数去比预算会误判某币不可做。shadow_report 加了
tick_floor() 标注;方向上安全(真实漂移只会更小,这些是上界)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:50:09 +08:00
jackyu66gitandCursor 27a4360ce8 「有前序信号」通过样本外验证:6/6 时段、4/4 ATR 分层全部同向
step48 那条唯一可交易的线索(开仓时过去 5 分钟内已有别的币发过信号,
则滑点余量更高)用更长历史验证。10 币 × 80 万根,2025-02 ~ 2026-08 共
3941 笔,发现期 1161 笔、样本外 2780 笔。

  样本外 无前序 2326 笔  毛R 1.086  PF 3.91  余量 18.83bp
  样本外 有前序  454 笔  毛R 1.275  PF 4.60  余量 24.89bp

逐时段 6/6 全部同向,余量差中位 8.03bp。发现期毛R 差只有 0.085,样本外
放大到 0.189——不是过拟合衰减,是发现期恰好偏保守。占比各时段 13~19%,很稳。

ATR 混淆已排除:有前序的 ATR 确实略高(中位 15.04 vs 13.13bp),但按四分位
分层后 4/4 层同向,层内余量差 3.29/8.39/5.16/4.14bp,与不分层的 +6.06 同
量级。毛R 本身是 ATR 归一化指标,其 +0.19 不可能是 ATR 假象。

可用方式:这 16% 的信号多容忍约 6bp 执行成本,可加仓位权重,或对这批放宽
ATR 门控(最低 ATR 层里有前序余量仍有 14.92bp vs 对照 11.63)。放宽门控尚未
回测,先别改。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:36:39 +08:00
jack 7c13781f90 Merge branch 'chan' of ssh://git.jackyu66.com:2222/jack/chan into chan 2026-08-28 04:35:17 +08:00
jackandCursor d13a0f85bc 固化窗口召回率:2000 根窗口不丢信号(90/90)
全量历史找出的最近 30 笔信号,在 2000 根窗口里逐笔重算,BTC/ETH/SOL 各
30/30 全部复现且过全部滤网。这是窗口左边界效应的一半答案:窗口不丢信号。

另一半没答,且对实盘更危险——窗口会不会多造出全量历史没有的信号(会多开
仓)。那要反向扫描:遍历窗口找命中再回全量核对。lean 之后单窗 130ms,抽样
2 万个窗口约 43 分钟,已经可做,docstring 里记了。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:35:12 +08:00
jackyu66gitandCursor 550eafdd9d 簇内顺序看着是圣杯,43% 的落差里有 34 个点是未来信息
用户提出研究多币同时段开仓的先后顺序。事后按位次切落差很大:簇内第 1 笔
胜率 81.6%、毛R 1.441,第 2 笔 1.085,第 3 笔 0.733,比孤立组 0.832 高 73%。

但「我是首发」的含义是「接下来 5 分钟没有别的币再发」,这是未来信息。首发
赢面大恰恰因为后面真跟出来了别的币、那波行情是真的,而会不会跟出来在下单
那一刻不可知。

换成开仓时真正可知的信息(过去 5 分钟有无别的币先发):

  无前序      979 笔 (84%)  毛R 0.938  PF 3.19  余量 14.13bp
  有前序 ≥1 个 182 笔 (16%)  毛R 1.021  PF 3.71  余量 21.88bp

43% 的落差塌成 8.8%,方向还反过来。但残留不是零,且滑点余量高 55%
(14.13 → 21.88bp),对 1m 是实打实的——1m 的生死线就在执行成本。

谁在领跑无稳定结构:首发率 BTC 8.6% ~ DOGE 18.4%,158 次首发摊到 10 个币
平均 15.8 次,离散度基本是抽样噪声。

给这个方向定了条纪律:组合空间大而样本只有 158 个簇,每个切法必须能写成
「开仓那一刻已知的信息」。凡用到簇共几个币、我是第几个、簇跨度多长的,
都含未来信息。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:27:43 +08:00
jack 99ae072cc6 Merge branch 'chan' of ssh://git.jackyu66.com:2222/jack/chan into chan 2026-08-28 04:24:54 +08:00
jackandCursor d37bfcb26b 心跳的优化建议改看绝对量级
lean + 新引擎后纯计算约 128ms,仍固定提示「需改增量计算」是误导:尾部已由
数据腿主导(lag_data P90 1482ms vs compute P90 237ms),压计算换不到东西。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:24:48 +08:00
jackyu66gitandCursor 2a59dcb9dc 扎堆开仓反而更赚,但好处全在「错开几分钟」那一档,同分钟的最差
承接上一条:既然同时开的必然同向,那关键是这批交易比孤立的好还是坏。
11 币 / 1161 笔 / 实盘口径:

  真孤立(±5 分钟内无同伴)  763 笔  胜率 65.5%  毛R 0.832  PF 2.83
  错开:5 分钟内但不同分钟   240 笔  胜率 82.1%  毛R 1.443  PF 7.31
  同一分钟撞在一起          158 笔  胜率 63.3%  毛R 0.777  PF 2.20

必须把两者分开——结论相反,混在一起会得出错误判断。错开的是全样本最好的
一档,同分钟的反而略差于孤立组。机制上:错开 = 行情从某个币扩散开,后发是
对先发的确认;同分钟 = 全市场同时被一个冲击打中,即追高。

簇级复核(±5 分钟合一簇,排除重复计数):多笔簇簇均毛R 1.180 vs 单笔簇
0.832,簇级 R 夏普 0.848 vs 0.459,结论不是重复计数撑起来的。多笔簇内
全赢 59.5%、全输 10.1%,簇内风险不可分散但偏度有利。

集中度上两类没差别(整簇同向 99.4%),差别纯在收益。所以「限制最多 N 个
并发仓位」把两类一视同仁是错的,它们期望收益差 1.9 倍。

注意:同分钟 vs 错开是看过数据后才划的切法,不是事先定的,208 天 158 个簇
容易切出噪声。当仓位规则用之前必须换一段时间验证。目前只有「扎堆整体更好」
是稳的(簇级也成立)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:24:31 +08:00
jack 4a4f8727fd Merge branch 'chan' of ssh://git.jackyu66.com:2222/jack/chan into chan 2026-08-28 04:24:22 +08:00
jackandCursor 6c843639d5 影子路径开 lean 模式,实测完整链路 1410 → 683ms
拉到新引擎后在服务器侧直接实测,没有沿用 3.4 倍那个换算——那是在旧代码上量的。

先做等价性验证。不能直接引用 step46 的对拍结论:它固化的是 bsp_list 那条链的
哈希,而影子路径走 find_fast_bsp3 + build_htf_zones + htf_fx_timeline +
attach_htf_context,两条链读的东西不一样。所以新建 verify_lean_parity.py 在
这条路径上逐根对拍 compute() 的每个返回字段。

其中一个坑:随机取窗口测不到信号分支。信号密度约 1/2000 根,头 12 个窗口命中
0 个,「一致」只覆盖了早退路径。改成一半窗口对齐到已知信号根,命中率才上来。
最终 180 窗口 / 两模式各 90 命中 / 零分歧。

生产实测(inner_ms,容器内同口径):

  compute_ms      646 → 132ms   4.89x
  inner_ms        612 → 128ms   4.80x
  lag_signal_ms  1410 → 683ms   完整链路,落回 800ms 线内

比 3.4 倍更好,因为是引擎 ~3.5x 叠 lean ~1.35x。

两点判读上的订正:

- lag_data_ms 那 576→490ms 是噪声,不要记在引擎账上。均值 721±36 vs
  740±127,重叠;而且引擎本来就影响不到交易所与网络那一段。
- 仍有 44% 的根超 800ms,但尾部现在完全由数据腿主导(lag_data P90 1482ms
  vs compute P90 237ms)。计算既不是瓶颈也不是尾部主因了,继续压计算换不到
  尾部改善。800ms 那道闸取的是最近 30 根的中位数,683ms 已满足。

start.sh 加 SHADOW_LEAN(默认 1),设 0 可退回 full 复量两模式差异。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:24:08 +08:00
jackyu66gitandCursor 9f4d7fec73 11 币的开仓时刻:不是同时开,但同时开的那批 100% 同向
用户问 11 个币的开仓时间差距。1161 笔 / 208 天 / 实盘口径(深色 ∧ ATR≥8bp):

相邻两笔间隔中位 122 分钟,62.8% 超过 1 小时,每天仅 5.58 笔。
同时持仓数:90.9% 的时间空仓,7.5% 只有 1 仓,≥2 仓合计 1.7%,峰值 8。

所以绝大多数时候不会撞车,但左尾是硬的:7.4% 与前一笔同分钟、20.7% 在
5 分钟内。而同一分钟出现多笔的 72 个时刻里,方向完全一致的占 100%
(§3.31 此前测到的是 92.7%,全样本下更极端)。

这批同时开的仓不是分散,是同一笔押注被拆到几个币上做——名义 3 个仓位,
实质 3 倍单向敞口。由此两条:保证金不是约束(91% 时间空仓,峰值并发只占
0.1% 的时间),真问题是资金闲置;并发上限必须按同向净敞口设,按仓位个数
设等于默许成倍的单向敞口。

另:TRX 在实盘口径下 208 天只有 5 笔,ATR 门控几乎全刷掉,应从币池剔除,
实际可用是 10 个币。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:17:19 +08:00
jackyu66gitandCursor d2fcb27d01 否掉 bis[2]:修右边缘重画会把 alpha 打成零,重画是必付代价
§5.41 发现 available_ts 取「中枢最后一笔」是右边缘重画的根因,改取「第三笔」
能把重画率从 6.5% 压到 1.2%,当时据此判断它是「唯一可能同时改善收益与稳定性」
的改动。那个判断只测了稳定性,过早了。

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:05:01 +08:00
jackyu66gitandCursor 0b4d7693b8 缠论引擎提速 2.6x,瓶颈是逐行 Series 查找而非指标计算
原以为浪费在 add_indicators 算了太多用不到的指标,实测它只占全量构建的
1.3%——talib 是向量化 C 代码,便宜。真正的两处:

cal_kl_data 占 96%:每根 K 线 df.iloc[i] 新建一个 40 列 Series,再在其上做
几十次逐键查找。改为预取 ndarray 后 2 万根 1946ms → 824ms。

ChanKLC.cal_all_ema_status 占 25%:每次合并 KLU 都立即重算,而它产出的
ema_status / ema52_pos / ema52_status 全仓无任何读取方(含前端)。改为惰性
求值,保留属性形式以防将来有人读。顺带删掉 get_klc_list 里累加一整轮后直接
丢弃的 ema_up_list / ema_down_list。

另加 TF_DF(lean=True):只构建到中枢,跳过线段/走势中枢/MACD 状态机——这些
只服务 bsp_list 与 web 展示,笔和中枢不依赖。研究与实盘走这条快 3.6x。

结果 2 万根 5m:full 1946 → 754ms,lean → 543ms。

step46_engine_parity.py 是配套的安全网,改引擎前先跑一次 --save。它对 KLC
端点与分型、笔起止价与 is_sure、中枢 zg/zd/available_ts/阶梯、信号全部输出列,
以及 26 个被下游消费的 dataframe 列取哈希。本次三处改动逐步验证,另用
git stash 切回改动前代码在 20 万根 × 5 用例上做了跨版本逐位对拍,全部一致;
增量路径与 web API 也各验一遍。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:04:44 +08:00
jackandCursor 3b14bba247 离线验证信号时刻流动性不更差,免掉攒 30 笔信号的 17 天等待
三币过完三滤网只有 1.72 笔/天,攒 30 笔要 17 天。但滑点本来每根都在记,信号
时刻的测量只多回答一个问题:信号那一刻的流动性是否比普通根差。这个问题可以
用 210 天历史离线回答。

关键是必须做匹配对照。信号按 ATR ≥ 8bp 门控,信号根天然比平均根波动大,直接
和全体根比一定会「发现」一个我们自己施加的差异。所以对照组按「同时段 × 同
ATR 十分位」抽取,并排除距信号 48 根内的根(持仓期不独立)。自检 ATR 比值
0.976~1.004,匹配成立。

结果三币一致,方向与担心的相反:信号根成交额是对照的 2.1~2.6 倍、Amihud 非
流动性只有 0.47~0.60 倍、Roll 有效价差三个币都不显著。信号跟在突破后面,
突破自带成交量。所以全体根测出的冲击与价差偏保守而非偏乐观。

唯一真实差异是根内波幅宽 25~28%,但那是波动而非流动性,对应漂移而非冲击,
且可直接当缩放系数:1 秒延迟点漂移上调后仍只占预算 1.4%/3.0%/4.5%。

判读按流动性与波动分两组。初版把 range_bp 当成流动性红旗,会得出「信号时刻
更差、必须等样本」的相反结论——它是波动度量,且 ATR 已匹配。
检验用置换而非 t:成交额跨几个数量级,右尾太重。scipy 未安装,也不宜在采集
运行中动环境,所以用 numpy 自己实现。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 03:52:23 +08:00
jackandCursor 75fbf4167b 修掉 gzip 追加会毁掉整个文件的数据丢失,并分开买卖两侧的成交分布曲线
两件事,都是「静默出错」那一类。

一、BookLog/TapeLog 追加到同一个 .gz,进程被 SIGKILL 时当前成员停在 deflate
块中间,下一轮追加的新成员接在垃圾字节之后。顺序解压在损坏点抛 invalid
block type,该点之后全部读不出来——包括后续每轮写进去的。而读侧的异常处理
把这个当成「正常的尾部截断」静默跳过,于是只读出 21 行还不报错。
原 docstring 里写的「只丢最后一个缓冲块,不会毁掉整个文件」是错的,已证伪。

写侧改成每轮运行一个文件;读侧按 gzip 成员边界扫描、坏成员单独跳过并出声
报告,同时把同前缀的多轮文件一并读入。旧损坏文件因此多恢复出 31/21 条
(tape)与 132/95 条(books)。

二、tape_shape 只统计主动买、只自区间顶部累积,这条曲线只适用于多头止盈。
exit_fill 两侧共用它,等于把空头的可成交量按多头分布高估。实测二者不对称:
主动买在顶部 20% 内已占 40%,主动卖在底部 20% 内只有 18%。分成 SHAPE_F 与
SHAPE_F_SHORT,avail_at 按方向查各自曲线。

两条曲线只有 45 根成交流样本,所以补了 --sensitivity:把空头可成交量砍一半,
10 万仓位下 BTC/ETH 预算完全不动、SOL 动 0.07bp。结论不依赖这 45 根样本。
顺带撤掉 step43 docstring 里已作废的「成交率 30%/16%/1.5%」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 03:23:07 +08:00
jackandCursor 54792fe015 把 compute_ms 拆成排队与纯计算,据此否掉换机器这个方向
compute_ms 一直是「提交进程池到拿到结果」的墙钟时间,排队和纯计算混在一个
数里,所以「加核有没有用」只能靠猜——这也是原先打算在 AWS 开第二台比 CPU
的依据。

worker 内部自己计时,连同父进程传入的提交时刻一起回传,拆出 queue_ms 与
inner_ms。实测中位 3ms / 695ms:2 个 worker 跑 3 个币并不排队,因为三个币的
收盘消息错峰到达。瓶颈全在单线程,加核压不到。

顺带把 README 里的内存数据从臆测的 1.5GB 改成实测 410MiB,并注明跨站点比
CPU 收益有限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 03:02:06 +08:00
jackandCursor 95f2b614a2 延迟瓶颈实测为本机 CPU 而非机房位置,跨站对比主指标改为 compute_ms
用户指出本机选新加坡是因为 Bitget 机房在新加坡。查证下来 ws.bitget.com 解析
到的是 CloudFront(dxotqhr62n6z4.cloudfront.net),落在新加坡 AS16509
(Amazon),即连的是 AWS 的 CDN 边缘而非 Bitget 自有机房。

腾讯云 ap-singapore(AS132203)到该边缘实测:ICMP 往返 2.1ms、TCP 握手
3.3ms、TLS 完成 9.0ms、首字节 87.8ms。首字节减 TLS 那约 79ms 是 CloudFront
回源开销,与我们的位置无关。

延迟构成(33 根样本):数据到达中位 506ms、信号计算中位 646ms、合计 1315ms。
随机房位置变化的只有那 2ms 往返,占总延迟 0.15%。而 646ms 的信号计算是每根
在 2000 根 1m 加 800 根 5m 上重建缠论结构,本机 2 核、2 个计算进程,三币同时
收盘时第三个还要排队——这才是有改善空间的一项。

因此:
- 新增 deploy/netprobe.sh,把网络那一段单独量出来并入运行元数据。用到达
  延迟去比两个机房等于用公斤秤称克,必须把可变的那段拿出来单独看。
- compare_sites.py 主指标改为 compute_ms,lag_data_ms 降为自检项(两站应当
  接近;若差很多,先怀疑时钟而非网络)。元数据表加 nproc/cpu_model/往返。
- README 改写:第二台机器该测 CPU 规格而非地理位置,WORKERS 按核数减一给,
  币数多于 worker 数时排队时间直接计入 compute_ms。

另修正站点标签:本机是腾讯云而非 Hetzner,sg-hetzner → sg-tencent,标错的
33 行数据已清掉重采。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:51:51 +08:00
jackandCursor 1661bbbac9 gitignore: 运行元数据属采集产物,不入库
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:40:57 +08:00
jackandCursor 631d97e493 加跨地部署脚本与站点对比,数据全表加 site 列
目的是在第二台机器(AWS)上跑一套完全相同的采集,看不同地理位置的数据差异。
滑点里最大的一项是延迟漂移,而延迟含网络传输,所以机房选址是可优化参数。

数据侧两处必需改动:

- 全部输出加 site 列(三张 CSV 加 gzip 里的盘口与成交流)。没有这一列,两台
  机器的数据合起来就分不清来源。为免四处 writerow 漏加一处产生静默空值,
  改在 _SiteWriter 里统一注入。
- start.sh 在时钟未同步或偏移超 10ms 时**拒绝启动**。所有延迟数字都是
  「本地时钟 − 交易所 K 线收盘」,时钟偏 50ms 就全部同向偏 50ms,且不报错,
  只会让跨地对比得出一个干净且完全错误的结论。

deploy/ 下四个文件:setup.sh(docker + chrony + 拉镜像)、start.sh(校验时钟、
写运行元数据、起容器)、status.sh(健康速查)、README。运行元数据记 git commit、
镜像摘要、时钟偏移——两地数据对不上时,这三项任一不同都足以解释差异。

compare_sites.py 做配对对比:只取各站都有的 K 线(不取交集可能在比不同时段,
而延迟对市场活跃度敏感),并报配对差的符号占比而非两个中位数相减。已用注入
120ms 的合成数据验证能精确还原。另有一条自检:同一固定延迟点上两站漂移应当
相同——漂移是市场性质,若也差很多则先查时钟与时段对齐。

status.sh 里按列名取字段下标而非写死数字:加 site 列时字段整体右移过一次,
写死 $8 会静默变成读 lag_signal_ms 而非 lag_data_ms。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:39:47 +08:00
jackandCursor 01c4a4ab4d 主口径仓位定为 10 万 USDT,并量出限价单前方的排队量
用户 2026-08-28 确认资金规模不会更大,故仓位档改为 25k/50k/100k/200k
(上下留档是为了读出局部斜率,单点看不出再大一倍会怎样)。该规模下两条
约束都不绑定:冲击占预算 0.1~11.1%,成交量效应使预算降幅不足 1%。

新增 queue_ahead:排队是唯一还没建模的成本项,exit_fill 假定我们能吃到该
价位的全部对手方成交量。10 万仓位相对最优档为 BTC 0.2 倍、ETH 0.7 倍、
SOL 69.7 倍。SOL 畸高是 tick 更细所致(同样的量摊到约 10 倍价位上),
对它应看 5bp 档(0.17 倍),但仍是三币中排队压力最大者。

同时在 shadow_depth 模块头标注:composite_fill 只算首次触及那一根的可成交
量,系统性偏悲观,不作为成交率结论——真实成交率见 lib/exit_fill.py。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:39:47 +08:00
jackandCursor 28075173af 出场模型改为按成交量结算止盈限价单,并出预算对仓位规模的曲线
exit_model.walk_exits 给止盈记的毛收益是 target*a/entry,即假定限价单全额
成交在目标价。新增 lib/exit_fill.py:两张挂单常驻(半仓 3ATR、半仓 8ATR),
每根按该根在限价之上的可成交量逐步吃进,未成交部分继续持有,止损触发时
市价平掉剩余。可成交量 = 形状函数 f(k) × 该根主动买成交额,f 由影子成交流
实测(近似线性,即区间内均匀分布,故结论对形状假设不敏感)。

结果:预算对仓位规模远比预期稳健。到 100 万名义额,BTC 10.97→10.69、
ETH 15.34→15.20、SOL 17.21→15.83bp。原因是挂单常驻多根而非只在首次触及
那一根成交,且价格决定性穿过限价时整根成交量都可用。

首版实现有个静默 bug 值得记:avail_above 里有个 `hi <= 0` 的守卫,而空头
用「价格取负」处理,负价格空间里 hi 恒为负——所有空头挂单的可成交量一律
判 0,空头全被拖到 48 根超时收盘。下跌段里那比 3ATR 目标赚得多,于是预算
反而偏高 0.76bp,表现为「一个看似合理的模型差异」。已改为显式方向参数,
并加 assert_converges:仓位趋近 0 时必须逐笔收敛到 walk_exits,不符即抛错。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:39:47 +08:00
jackandCursor 181bca303f 影子测量改用框架吃单原语,并补齐容量与 maker 成交率两项测算
吃单查询换成 Hummingbot 的 OrderBook.get_vwap_for_volume:手写的 walk_book
返回的是按计价币吃单的加权均价,但框架的 get_price_for_quote_volume 返回
边际价、get_vwap_for_volume 收基础币量,两者语义不同。改为按基础币下单
(真实委托与 PositionExecutor.amount 均是基础币计价),深度不足由
query_volume/result_volume 判定,框架此时返回 nan 而非一个看似正常的
部分成交均价。

落盘完整盘口(双边 50 档)。此前只记三个固定名义额的成交价,这批数据的
寿命就等于那几个档位的寿命;存完整深度后任意资金量级的冲击都能离线重算。
仓位档同时从 1k/5k/20k 提到十万量级,此前低估真实仓位约两个数量级。

订阅成交流,按根按价位聚合。买卖分开存——多头在目标位挂卖出靠主动买盘
成交,混在一起会把成交率高估约一倍。BTC 每根总成交额中位与 210 天历史
的 volume×close 差 0.3%,可确认采集完整。

新增两项测算:
- 冲击不是绑定约束。32 万仓位单边冲击 0.19~2.39bp,对 8.58~20.64bp 的
  预算只占 1.6~14.2%,冲击反推的资金上限 100~500 万。
- maker 成交率才是。止盈位被首次触及时,限价在该根价格区间中的位置
  中位 k=0.28(63.9 万次触及,三币一致);合并每根成交额后,32 万仓位
  的全额成交率仅 30.1%/15.6%/1.5%。要 80% 全额成交,仓位须 ≤ 4.7 万
  /1.4 万/0.26 万——比冲击反推的上限低 40~370 倍。

回测把这些止盈按「全额成交在目标价」计,故预算所依据的收益流本身需重估。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:39:47 +08:00
jackyu66gitandCursor 6259f7380b research: 重算新出场口径下的并发,并修正「各笔基本独立」的说法
分批出场把 1m 平均持仓从 9.9 根拉到 29 根,§3.3 的并发数字是旧口径的。
重算后 8 币组合平均并发 0.043→0.120、有仓位时间 4.0%→10.2%,峰值仍是 6。

更要紧的是顺带查出来的相关性:同一小时内有 ≥2 个币发信号的时段占 20.9%,
其中 92.7% 方向完全一致。所以「有仓位时间低 → 各笔基本独立」这个推理不成立
——时间上不重叠不等于统计上独立。t 值有一定虚高(不足以推翻,t 在 43 以上),
但更实际的后果是:加币不产生分散,仓位不能按「1% × N 个币」线性放。

用户问「整个市场都是正相关的,是不是很少有独立行情」。市场相关是真的,
但这不是伪装成策略的 beta:多空各占 50.7% / 49.3%,做空 PF 4.40 还略好于
做多 4.05,所有时段净方向合计仅 +185 笔。分年看,2021 大牛年做空的 PF 5.34
是整张表最高的一格,七年里没有一年、没有一个方向是亏的。空头占比随行情
切换(牛市 46.5% → 熊市 55.9%)。

所以 92.7% 同向该理解为「检测器正确识别到全市场级别的结构」——若 6 个币
同时发信号却方向随机,那才说明信号是噪声。

另补 3.33 扩币筛选:ATR 对门控阈值与流动性是两条方向相反的约束,最优区间
在中间。BTC 输在波动不够(ATR 中位 2026 仅 6.5bp,预算垫底),TRX 2026 门控
后只剩 4.2% 信号。保证金约束那条待办从「预计影响小」改为扩币前置条件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 00:45:32 +08:00
jackyu66gitandCursor 32c18f0201 research: 1m 出场口径重定、费率修正,与影子测量的判据常数
起因是用户看图指出「止盈没做好」,查下来 TP=3.0 确实把右尾截早了,
而且 1m 不该沿用 5m/15m/30m 的参数——成本固定在 bp、目标随 ATR 缩放,
1m 的 3 ATR 只有 0.39% 而 30m 是 1.87%,成本占比差 5 倍。

step41(5m/15m/30m)与 step42(1m)跑同一张全网格:
SL × TP × MAX_BARS × 分批(3 ATR 减半 → 剩余目标 × 剩余半仓止损位)。

- 1m 最优 SL2 / 3ATR 减半 / 剩余止损保持 2.0 / 目标 8ATR / 48 根,
  样本外 8/8 币、7/7 年全面提升,均R/R夏普/回撤/剔10%PF 四项全赢
- 分批要做,但**减仓后不要动止损**。止损位 0/0.5/1/1.5/2 ATR 严格单调,
  越紧越差,三组初始 SL 全一致。保本损是全表最差的一档
- SL=1.0 在 1m 上是废的:剔10%PF 0.78~0.99、中位收益 −0.122%

费率此前写的 taker 3bp / maker 1bp 隐含「原始 taker 6bp」的错误前提,
实际是原始 taker 0.040% / maker 0.016%、返 50% 后 2.0 / 0.8bp。方向是保守的,
所以首轮跑出来的数字全部偏低。exit_model 已改,费率只在分析阶段套用,
不必重跑模拟。改完 1m 的均R +8%,5m/15m/30m 只动 2%——费率只对 1m 有杠杆。

顺带查证了用户的一个假设:余量逐年递减是不是跟波动率有关。成立,而且
r = +0.989。毛/ATR 七年在 2.26~2.67 之间没有趋势,衰减的是 ATR 本身
(2021 的 22.1bp 压到 2026 的 8.8bp)。**是波动率压缩,不是 alpha 衰减。**

由此引出 ATR 门控:低 ATR 桶的毛 R 其实最高(1.16 vs 高 ATR 桶的 0.94),
断崖只在扣费之后出现。所以阈值是**费率的函数**(约 5 + 1.1×taker费),
不是市场常数。当前费率下 ≥8bp,在 5m/15m/30m 上几乎不触发,可作全局规则。

lib/shadow_budget.py 放影子测量要对照的常数:逐币预算、门控阈值、
腿→maker/taker 映射、lag 阈值、判据。记录与报表归 research/live/,
分工的理由是这些数会变——今天预算就动了四次。

out/*.feather 转为 ignore:70MB+ 且重跑可得,摘要都在 HANDOFF。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 00:05:56 +08:00
jackyu66gitandCursor 9b72173285 feat: 第四类买卖点(B4/S4)融入缠论引擎与 web 展示
研究侧的 fast_bsp3 一直只活在 research/lib/ 里,web 端看不到,回测与目视
两条线对不上。这次把它搬进引擎,作为独立的第四类买卖点。

之所以单独立类而不是当作 B3/S3 的低滞后版:step30/31 显示引擎原生的
B3/S3 统计上呈逆势、显著亏损(胜率 27.4%、PF 0.66、t −18.76),而同一组
过滤器把 B4 从 PF 1.59 提到 2.26 却对它无效(0.66→0.71)。两者选的是
不同的交易群体,不是同一信号的早晚两版。

- chanlun/analysis/fast_bsp.py 原样搬入 find_fast_bsp3 与 build_htf_zones,
  另加 add_zone_ladder / htf_fx_timeline / attach_htf_agree
- research/lib/ 两个模块改为转发,所有 step 脚本导入不变,信号逐条比对一致
- 大级别上下文用 resample 从同一份 df 构建,不额外拉数据,因此与界面上选的
  周期和时间范围无关
- 前端三个复选框 + 过滤模式下拉;未过滤的原始信号用浅色,避免与主口径混淆

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 00:05:26 +08:00
UbuntuandCursor 7e339d2a54 research: 影子交易器落在 Hummingbot 上,并修掉 Bitget 连接器的换根延迟
1m 腿的滑点余量只有几个 bp,所以要测的必须是生产路径的滑点——换个运行时
测出来的数就不作数。框架因此从「滑点已知后再定」提前到测量阶段就定为
Hummingbot(Spot/Perp 连接器均 v2.0,Bitget 是 Foundation Partner)。

新增 research/live/。前置测量:

- bench_compute.py 本机算力,1m 单币 0.318s、三币串行 1.38s
- venue_parity.py Binance 与 Bitget 同根信号重合仅 14.6~42.6%
- signal_sensitivity.py 0.25bp 扰动就换掉一半信号
- aggregate_robustness.py 但总体期望不降——脆的是信号身份,不是 alpha
- bitget_baseline.py 因此改用 Bitget 原生基线定预算:余量 BTC -0.13bp、
  ETH +4.02bp、SOL +2.92bp。BTC 本就为负,只作延迟测量的参照物

运行时选型:

- parity_env.py 容器与本机信号逐一相同(下标、中枢数、checksum 全等),
  容器内 0.26s/币反而更快。故 chanlun 直接挂载进容器,不必另起信号服务。
  装进现有 .venv 那条路走不通:Hummingbot 要 numba>=0.61.2 与
  aiohttp<3.14,与本机 Python 3.14 冲突
- latency_ccxt.py / latency_hummingbot.py / latency_compare.py 初测显示
  Hummingbot 比 ccxt.pro 慢约 1030ms,90 根逐根配对里 80~97% 更慢
- probe_ws_action.py 否掉「丢弃 snapshot」的猜测:换根首条就是 update
- probe_hb_vs_raw.py 与 latency_attribute.py 四路归因——容器网络 2~18ms、
  Hummingbot 处理 -10~-30ms,1350~1480ms 全落在解析方式上
- probe_ws_payload.py 定位根因:Bitget 换根会推一条带两根的消息
  [上一根, 新一根],而上游取 data["data"][0] 拿到的是上一根,新一根要等
  下一条单元素消息

修复:

- patched_candles.py 处理消息里的全部元素。不能简单改成 [-1]——那样上一根
  的收盘价会永远停在换根前约 1 秒的那次推送上,而信号对 0.25bp 都敏感
- verify_patch.py 60 根配对验证:拿回 1060~1090ms,与原始 WS 只差 5~14ms
  已贴理论下限,19 根已收盘 K 线 OHLCV 逐根未变。折算 ETH 省 0.54bp、
  SOL 省 0.42bp。此 bug 值得向上游反馈

影子交易器:

- shadow_hb.py 不下单,读连接器真实盘口按仓位吃单深度算成交价,与次根开盘价
  (回测 entry_delay=1 的口径)相减,分解成延迟漂移、盘口价差、深度冲击。
  盘口 10Hz 滚动缓冲 30 秒,把延迟变成自变量:每个信号记 0.5/1/2/5s 与实际
  算完时刻各一个滑点值,本机算得慢也不影响能读出的曲线
- shadow_signal.py 信号计算隔离到子进程。0.26s 是纯 CPU 且 chanlun 受 GIL
  限制,放进 asyncio 循环会把行情处理一起卡住
- shadow_report.py 首日延迟门槛与滑点曲线报表

不用 paper trade 测滑点:它的成交由 Hummingbot 自己的撮合模型模拟,
测出来是模型行为而非市场行为。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 23:51:57 +08:00
jackyu66gitandCursor 66061f79a1 research: 低滞后信号口径定稿与实盘前偏差审计
fast_bsp3 改用 tol=-1 + require_touch=False,信号滞后从 5.8 根降到 2.2 根。
滞后与收益严格单调(年化 370% -> 906%,同一份数据同一套成本),
这是本轮提升的主因,也意味着实盘延迟会直接侵蚀收益。

新增 step31~39 验证策略能否落地:
- 跨品种样本外——8 个未参与调参的币,PF 2.73 / t 28.5,无一为负
- 时点重建——只喂到信号那一根重算,同根命中 100%,确认无未来函数;
  1m 在 2000 根窗口即饱和,计算耗时 0.20s
- 偏差审计——多空对称、中枢生效时刻零回退、滑点稳健至 30bp、持仓几乎不重叠
- 消融——alpha 来自缠论中枢的上下文定位,而非「收盘转强」这个触发动作

补 research/HANDOFF.md:记录确切口径与参数、已排除的偏差、
已验证无效因而不必重做的方向,以及下一步用影子交易器实测执行滑点的方案。

清理 step1~20 的输出:早期方法论已被推翻(存在未来函数偏差),
其结论不再被引用;脚本保留,需要时可重跑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 17:47:41 +08:00
UbuntuandCursor b286cb0137 chore: 忽略 Office 文档与虚拟环境目录
手续费.xlsx 一类文件放在仓库旁但不属于仓库;~$ 开头的是 Excel 打开工作簿
时生成的锁文件,每次都会重新出现并占据 git status。

.venv 一并声明:venv 自 3.11 起会在它创建的目录内写 .gitignore,但仅覆盖
该目录,换个位置或用旧版本就失效。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 02:08:12 +08:00
UbuntuandCursor a243edd2d3 docs: 补依赖清单与 README
此前仓库无任何依赖声明。按 AST 扫描全部 import 后按用途切分:
requirements.txt 为核心运行时 8 个包,requirements-dev.txt 追加测试与
research/ 所需。

matplotlib / mplfinance / xgboost / scikit-learn 只出现在 ChanPY.py、
ChanLun_Classifier.py、Find_Trend.py、ChanHeng.py 这四个零引用文件中,
故不纳入清单;ChanPY.py 依赖的外部 chan.py 库本就未安装。

README 记录目录结构、启动方式、库用法、分析流程九步与数据约定,并注明
三处现存问题:web/DEPLOY_GUIDE.md 引用的 6 个部署脚本已在 7f393b9 删除、
web/README.txt 指向不存在的 web/requirements.txt、systemd unit 的端口
8123 与 config.py 默认的 8128 不一致且 gunicorn 未声明。

清单已在全新空 venv 中验证:仅装 requirements.txt 时 57 个模块可导入、
Flask 16 条路由正常;装 dev 清单后测试 24 通过。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 02:01:53 +08:00
UbuntuandCursor 206b27fe72 refactor: 以自实现指标替换 talib 与 technical 依赖
chanlun/indicators/ta.py 接口兼容 talib.abstract,实现代码实际用到的
SMA/MA/EMA/RSI/ATR/MACD/BBANDS;chanlun/pipeline/resample.py 替代
technical.util.resample_to_interval。调用点只改 import,逻辑未动。

暖机长度与平滑种子按 TA-Lib 的约定实现,差一根 K 线就会让下游所有
笔/线段/中枢整体位移。其中 MACD 需特别处理:TA-Lib 让快慢两条 EMA
在同一根 K 线出首值,因而快线的种子取 x[slow-fast:slow] 的均值,而非
从 fastperiod-1 一路递推——两者在百元价位上相差约 0.17。

BBANDS 是有意的分歧:TA-Lib 用 sumsq/n - mean² 求方差,短窗口远离零
时灾难性抵消(timeperiod=2 误差 8.7e-7),本实现用 rolling std,对 50
位精度基准误差为 0。项目实际使用的周期两者一致到 1e-10。

顺带清理 12 个文件中 16 处从未调用的 talib/technical 导入。

验证:9440 组随机对拨;真实 K 线端到端比对 add_indicators 全部 33 个
指标列,NaN 模式一致、MACD 柱符号 100% 相同;屏蔽两个包后 60 个模块
均可导入。新增 test_ta_compat.py 将输出逐 bar 钉在 TA-Lib 上,但该文件
在 TA-Lib 缺失时静默跳过,改动 ta.py 需在装有 TA-Lib 的环境复跑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 02:01:43 +08:00
jackyu66gitandCursor 7f393b93ed refactor: 精简仓库为 chanlun 核心与 web 分析,移除威科夫与遗留模块
删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 01:05:12 +08:00
jackyu66gitandCursor 5c10e35b76 refactor(web): 移除主图威科夫选项与叠层
去掉区间/阶段/时间/VP 开关、Cycle 摘要面板及绘制逻辑;分析请求默认 include_wyckoff=0。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 01:27:51 +08:00
jackyu66gitandCursor 8c165f11cd fix(web): 未完成笔/线段终点对齐图表最新 K 线
各周期使用对应 kline 数据,终点时间 snap 到 candles,优先使用分析 end_price。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 00:40:41 +08:00
jackyu66gitandCursor 97e77847d0 fix(web): 开关缠论元素保留视窗;分周期 Trend 涨跌配色
本地重绘统一冻结视窗;次/次次周期 Trend 上涨下跌使用独立颜色。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 23:56:55 +08:00
jackyu66gitandCursor 90499533fb fix(web): 分析/自动刷新后保留 K 线视窗位置
拆分手动分析与自动刷新拉数路径;全量重建用 logical 优先恢复视窗,
增量 recent 用 scroll+barDelta;避免 barSpacing 重锚与重复冻结导致往右跳。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 23:38:20 +08:00
jackyu66gitandCursor 8ee11317d3 fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 22:57:43 +08:00
jackyu66gitandCursor 1e60ab3bfa docs: 补强 ECR-004 CODE_REVIEW 复审记录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:47:18 +08:00
jackyu66gitandCursor d3188ca83c fix: ECR-004 威科夫区间评分硬化与 VP 绘图减负(已审)
评分选 TR、阶段最小跨度、elements_only 门闩、Top-8 VP;无币种独立参数。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:46:08 +08:00
jackyu66gitandCursor ac6be80278 docs: 开启 ECR-004 威科夫硬化与 VP 减负(Draft)
跟进 ECR-003 Review Findings;待 Approve 后实现。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:35:25 +08:00
jackyu66gitandCursor 081a57a90e feat: ECR-003 主站威科夫分析与图表叠层(已审)
独立 wyckoff 引擎 + 按需 include_wyckoff;主站 Lightweight 绘制区间/阶段/事件/VP。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:33:57 +08:00
jackyu66gitandCursor df27b4dde8 refactor: ECR-002 拆分 runtime 包并加深 analyze 契约(已审)
将 web/services/runtime.py 拆为 runtime/ 子模块并保持门面兼容;补齐 ESS 文档、门面/契约/TF_DF 测试与 CODE_REVIEW Approve。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:15:23 +08:00
jackyu66gitandCursor 9f1e7361b6 fix: 修复主站自动刷新内存泄漏,并完善 chan_tv 图表体验
主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:09:48 +08:00
jackyu66gitandCursor 6b0f3b5837 release: 发布系统版本 v1.0.0(ECR-001)
落盘 CODE_REVIEW Approve 与 RELEASE_REPORT,标记首个正式 release。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:53:04 +08:00
jackyu66gitandCursor 74dec4e50b refactor: 缠论引擎包化与 Web 分层(ECR-001)
将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务;
前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:48:20 +08:00
jackyu66gitandCursor e2e45bc1bc chore: 移除不再使用的 ChanMacro、system、tests。
这些目录已废弃,从仓库中清理。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:11:29 +08:00
jackyu66gitandCursor f2e77e1bdb chore: 将 data_provider 拆出为独立仓库。
数据服务已迁移至 jack/data_provider,不再随 chan 维护。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:10:29 +08:00
jackyu66gitandCursor b31215057e chore: 将 bsp_monitor 拆出为独立仓库。
监控服务已迁移至 jack/bsp_monitor,不再随 chan 维护。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:09:43 +08:00
441 changed files with 211256 additions and 50537 deletions
+37 -28
View File
@@ -1,42 +1,51 @@
# MacOS
.DS_Store
# Python编译文件和缓存
# Python
__pycache__/
*.py[cod]
*$py.class
*.pyc
*.pyo
.pytest_cache/
# 策略文件的缓存
strategies/__pycache__/
# Machine Learning / AI model files
*_model*_xgb_model.json
*modelchan*.json
*.libsvm
feature_meta
*_model_feature_data.csv
*.pem
# Log files
# Logs & databases
*.log
# Database files
*.sqlite
*.sqlite-shm
*.sqlite-wal
.DS_Store
交易记录/~$交易规则.docx
/datasvc/data
.DS_Store
.DS_Store
/data_provider/data
.DS_Store
.DS_Store
.DS_Store
.DS_Store
.DS_Store
.DS_Store
data_provider/._config.json
# Office documents kept alongside the repo but not part of it.
# "~$" files are Excel's lock files, recreated every time a workbook is opened.
*.xlsx
*.xls
~$*
# Local data
data/
# Exchange history fetched by research/live/*.py. 40MB and re-fetchable from
# the venue, so it stays local; the small result CSVs it feeds are committed.
research/live/cache/
# Scratch outputs from short shakedown runs, superseded by the real collection.
research/out/archive/
# Per-trade simulation dumps from research/step*.py. 70MB+ and regenerable by
# rerunning the step; the summaries they feed live in HANDOFF.md.
research/out/*.feather
# Virtualenvs. venv writes its own .gitignore since 3.11, but only for the
# directory it creates — declare it here so other layouts are covered too.
.venv/
venv/
# Local tooling
.gstack/
research/out/*.jsonl.gz
research/out/penetration.csv
research/out/shadow_*.csv
research/out/run_meta_*.json
# Telegram 凭据。**不要提交**
research/live/deploy/tg.env
-110
View File
@@ -1,110 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
缠论 (Chan Theory) technical analysis system for Freqtrade. Implements Chan Zhong Shui Chan's theory for crypto/stock trading, including fractal (分型), stroke (笔), segment (线段), pivot/center (中枢), and buy/sell point (买卖点) detection.
## Core Architecture
### Chan Theory Engine (`Chan*.py`)
Data processing pipeline (each step feeds the next):
1. **`ChanKLU.py`** — Raw K-line unit with TA indicators (EMA, MACD, RSI, Bollinger Bands) and candlestick pattern recognition (`Chan_KLU_PATTERN`)
2. **`ChanKLC.py`** — Combined K-line: inclusion processing (包含处理), fractal (分型) detection. Linked-list structure with `.next`/`.pre` pointers
3. **`ChanBI.py`** — Stroke (笔): basic trend unit connecting alternating fractals
4. **`ChanSBI.py`** — Special Stroke: aggregates multiple BI into higher-level units with fractal detection, feeds into SEG
5. **`ChanSEG.py`** — Segment (线段): built from SBI strokes
6. **`ChanZS.py`** / **`ChanBIZS.py`** — Center/pivot (中枢): consolidation zones (segment-level and stroke-level)
7. **`ChanBSP.py`** — Buy/Sell points (买卖点): Type 1/2/3 signals
8. **`ChanLun.py`** — Main orchestrator: ties all steps together, entry point
9. **`TF_DF.py`** — Timeframe-aware DataFrame processor: resamples data, runs the full pipeline per timeframe, handles multi-timeframe analysis
### Support modules
- **`ChanEnum.py`** — All enumerations: K-line types, fractal types, MACD states, buy/sell point types, EMA position/semantic states, K-line patterns
- **`ChanCTime.py`** — Chan theory time utility: auto-adaptive day understanding (e.g. crypto 24h vs stock market hours)
- **`ChanMACD.py`** / **`ChanMACDHistSet.py`** / **`ChanMACDSeg.py`** / **`ChanMACDUnitTF.py`** — MACD state analysis and divergence detection
- **`ChanPY.py`** — Consolidation (盘整) analysis
- **`ChanHeng.py`** — Sideways market analysis
- **`Chan_FX_Box.py`** — Fractal box (分型箱体) detection
- **`ChanLun_Classifier.py`** — Standalone classifier script: runs full pipeline and classifies market states
### Services
- **`data_provider/`** — FastAPI data service: fetches crypto data from Binance via CCXT, caches to CSV, serves REST API + WebSocket. Synthesizes derived timeframes (e.g. 5m/15m/4h from 1m/1h base). Port 9009.
- **`web/`** — Flask web UI for interactive chart visualization with Chan theory overlays. Port 8123.
- **`strategies/`** — Freqtrade trading strategies using the Chan theory engine (53 strategies)
- **`config/`** — Freqtrade JSON config files per pair/timeframe
### Data Flow
```
Exchange (CCXT) → data_provider (CSV cache) → Freqtrade → Strategy → ChanLun → TF_DF
→ KLU → KLC → BI → SBI → SEG → ZS → BSP
```
## Common Commands
### Freqtrade Trading
```bash
# Live trade
freqtrade trade -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies
# Backtest
freqtrade backtesting -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies --timerange=20251008-
# Download data
freqtrade download-data -c ./user_data/Chan/config/<config>.json -t 1m 1h 1d --pairs BTC/USDT:USDT --timerange=20240101-
# Hyperopt
freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --spaces roi --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies -c ./user_data/Chan/config/<config>.json -e 200 --timerange=20250201-20250901
# Plot
freqtrade plot-dataframe --strategy <StrategyName> --datadir user_data/data/binance -c ./user_data/Chan/config/<config>.json --timerange=20250721-
```
### Data Provider
```bash
# Docker
cd data_provider && docker compose up -d
# Direct
cd data_provider && python main.py
# With custom config
CONFIG_PATH=./config.json python main.py
```
### Web UI
```bash
cd web && python app.py
# or via gunicorn:
gunicorn -w 4 -b 0.0.0.0:8123 app:app
# Deploy scripts:
cd web && ./deploy.sh # standard
cd web && ./deploy_venv.sh # Ubuntu 22.04+ (venv)
```
### Docker (Freqtrade)
```bash
sudo docker compose run --rm chanlun_btc backtesting -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies --timerange=20250721-
```
## Key Conventions
- All Chan theory classes are prefixed with `Chan` (e.g., `ChanBI`, `ChanZS`)
- Strategies import `ChanLun` and add `sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))` to import from parent
- MACD params: `MACD(26, 52, 9)` by default (slow period 52 instead of standard 26)
- Enums in `ChanEnum.py` use `auto()` values
- `ChanKLC` is a linked-list style data structure with `.next`/`.pre` pointers
- The `TF_DF` class is the primary data container per timeframe
- K-line direction uses `Chan_KLINE_DIR` (UP/DOWN/COMBINE/INCLUDED)
- All text comments/commits are in Chinese
-12
View File
@@ -1,12 +0,0 @@
# Data Provider URL (existing chan data_provider service)
PROVIDER_URL=http://127.0.0.1:80
# Database path
DB_PATH=data/macro.db
# Telegram (reuse bsp_monitor config)
# TELEGRAM_BOT_TOKEN=your_bot_token
# TELEGRAM_CHAT_ID=your_chat_id
# AI API (for daily report, Phase 5+)
# ANTHROPIC_API_KEY=sk-ant-...
-1
View File
@@ -1 +0,0 @@
data/
-9
View File
@@ -1,9 +0,0 @@
"""
ChanMacro — Crypto Market Memory System (Signal Expectancy Engine).
V1: 4 factors (Price Structure, Breadth, OI State, Volatility Regime)
3 regimes (TREND / RANGE / PANIC)
Factor-locked: Regime = f(Price, Breadth, Vol) — forever.
"""
__version__ = "1.0.0"
-224
View File
@@ -1,224 +0,0 @@
"""
chan_integration.py — 缠论引擎集成:检测 BSP 信号并写入 signal_features。
复用 bsp_monitor/engine.py 的 ChanEngine 管线,对历史日线数据批量跑缠论,
提取 B1/B2/B3/S1/S2/S3 信号,通过 SignalTracker 记录到 signal_features。
"""
import sys
import os
from datetime import date as Date, timedelta
from typing import List, Optional
import logging
# 确保 Chan 引擎在路径上(与 bsp_monitor/engine.py 相同的路径设置)
_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PARENT not in sys.path:
sys.path.insert(0, _PARENT)
import pandas as pd
from ChanEnum import Chan_BSP_TYPE, Chan_BSP_DIR
from ChanBSP import ChanBSP
logger = logging.getLogger(__name__)
class ChanSignalDetector:
"""
对历史日线数据运行缠论管线,提取所有 BSP 信号。
Usage:
detector = ChanSignalDetector()
signals = detector.detect_from_db("2026-01-01", "2026-06-24")
# → [{"date": Date, "signal_type": "B3", "entry_price": 96500, ...}, ...]
"""
def __init__(self):
from TF_DF import TF_DF as _TF_DF_Class
self._TF_DF_Class = _TF_DF_Class
def detect_from_db(self, start_date: str, end_date: str) -> list[dict]:
"""从数据库加载日线数据,跑缠论管线,提取信号。"""
from database import get_connection
conn = get_connection()
df = pd.read_sql_query(
"SELECT date, open, high, low, close, volume "
"FROM ohlcv_daily WHERE symbol='BTC/USDT:USDT' "
"AND date BETWEEN ? AND ? ORDER BY date",
conn, params=(start_date, end_date)
)
conn.close()
if df.empty or len(df) < 50:
logger.warning(f"日线数据不足: {len(df)}")
return []
return self.detect_from_df(df)
def detect_from_df(self, df: pd.DataFrame) -> list[dict]:
"""从 DataFrame 运行缠论管线,提取 BSP 信号。"""
# 需要 datetime 列才能跑 TF_DF
df = df.copy()
df["timestamp"] = pd.to_datetime(df["date"])
df["date"] = df["timestamp"]
try:
engine = self._build_engine(df)
except Exception as e:
logger.error(f"缠论管线失败: {e}")
return []
return self._extract_signals(engine)
def _build_engine(self, df: pd.DataFrame):
"""构建缠论管线(对齐 bsp_monitor/engine.py 的 ChanEngine)。"""
from TF_DF import TF_DF as _TF_DF_Class
if df.empty or len(df) < 50:
raise ValueError(f"数据不足: {len(df)} 根 K 线")
if "date" not in df.columns and "timestamp" in df.columns:
df["date"] = df["timestamp"]
# 使用 __new__ 避免触发 TF_DF.__init__
engine = type('ChanEngine', (), {})() # 简单容器
tf = _TF_DF_Class.__new__(_TF_DF_Class)
df_with_indicators = tf.add_indicators(df.copy())
engine.klu_list = tf.get_klu_list(df_with_indicators)
engine.klc_list = tf.get_klc_list(engine.klu_list)
engine.bi_list = tf.cal_bi_list(engine.klc_list)
engine.seg_list = tf.get_seg_list(engine.bi_list)
engine.bi_zs_list = tf.cal_bi_zs(engine.seg_list)
engine.bsp_list = tf.find_all_bsp(engine.bi_list, engine.bi_zs_list)
return engine
def _extract_signals(self, engine) -> list[dict]:
"""从 ChanEngine 输出中提取所有 BSP 信号。"""
signals = []
for bsp in engine.bsp_list:
if bsp.type == Chan_BSP_TYPE.NONE:
continue
if bsp.klc is None:
continue
signal_type = self._bsp_type_str(bsp.type)
entry_price = bsp.klc.close
signal_date = self._klc_date(bsp.klc)
if signal_date is None:
continue
# 信号质量:根据分型强度判断
strength = self._calc_strength(bsp)
grade = "A" if strength >= 70 else "B" if strength >= 50 else "C"
signals.append({
"date": signal_date,
"signal_type": signal_type,
"entry_price": float(entry_price),
"signal_grade": grade,
"signal_strength": float(strength),
})
details = ", ".join(f"{s['signal_type']}({s['date']})" for s in signals)
logger.info(f"检测到 {len(signals)} 个信号: {details}")
return signals
def populate_signal_features(self, start_date: str = "2024-01-01",
end_date: Optional[str] = None) -> int:
"""
完整流程:检测信号 → 计算市场状态 → 写入 signal_features。
Returns: 写入的信号数量。
"""
if end_date is None:
end_date = Date.today().isoformat()
logger.info(f"开始信号检测: {start_date}{end_date}")
# Step 1: 检测缠论信号
signals = self.detect_from_db(start_date, end_date)
if not signals:
logger.warning("未检测到任何 BSP 信号")
return 0
# Step 2: 去重 — 跳过已存在的信号
from database import get_connection
conn = get_connection()
existing = set()
for row in conn.execute(
"SELECT date, signal_type FROM signal_features"
).fetchall():
existing.add((row[0], row[1]))
conn.close()
new_signals = [s for s in signals
if (str(s["date"]), s["signal_type"]) not in existing]
if not new_signals:
logger.info("所有信号已存在,跳过")
return 0
# Step 3: 写入 signal_features
from expectancy.tracker import SignalTracker
tracker = SignalTracker()
count = tracker.backfill_signals(new_signals)
logger.info(f"信号入库完成: {count}/{len(signals)}")
return count
@staticmethod
def _bsp_type_str(t: Chan_BSP_TYPE) -> str:
mapping = {
Chan_BSP_TYPE.B1: "B1", Chan_BSP_TYPE.B2: "B2", Chan_BSP_TYPE.B3: "B3",
Chan_BSP_TYPE.S1: "S1", Chan_BSP_TYPE.S2: "S2", Chan_BSP_TYPE.S3: "S3",
}
return mapping.get(t, "UNKNOWN")
@staticmethod
def _klc_date(klc) -> Optional[Date]:
"""从 KLC 提取信号确认日期。"""
end_time = getattr(klc, "end_time", None)
if end_time is None:
start_time = getattr(klc, "start_time", None)
if start_time is None:
return None
end_time = start_time
if hasattr(end_time, "date"):
return end_time.date()
if isinstance(end_time, str):
return Date.fromisoformat(end_time[:10])
return None
@staticmethod
def _calc_strength(bsp: ChanBSP) -> float:
"""根据 BSP 特征计算信号强度 0-100。"""
score = 50.0
klc = bsp.klc
if klc is None:
return score
# 分型强度
from ChanEnum import Chan_KLC_FX
fx = getattr(klc, "klc_fx_type", None)
if fx is not None:
strong_fxs = {Chan_KLC_FX.TOP2, Chan_KLC_FX.TOP3, Chan_KLC_FX.BOTTOM2, Chan_KLC_FX.BOTTOM3}
medium_fxs = {Chan_KLC_FX.TOP1, Chan_KLC_FX.BOTTOM1, Chan_KLC_FX.TOP4, Chan_KLC_FX.BOTTOM4}
if fx in strong_fxs:
score += 25
elif fx in medium_fxs:
score += 10
# BSP 类型
if bsp.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1):
score += 10 # 一类买卖点: 背驰确认, 额外加分
# 笔特征
bi = getattr(bsp, "bi", None)
if bi and hasattr(bi, "height") and hasattr(bi, "width"):
if bi.width > 3 and abs(bi.height) > 100:
score += 10
return min(score, 100.0)
-464
View File
@@ -1,464 +0,0 @@
"""
cli.py — Command-line interface for ChanMacro.
"""
import argparse
import json
import logging
import time
from datetime import date as Date, datetime, timedelta
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("chanmacro")
def parse_date(date_str: str) -> Date:
"""Parse YYYY-MM-DD string to Date."""
return datetime.strptime(date_str, "%Y-%m-%d").date()
def _build_market_state(target: Date) -> tuple:
"""Shared helper: compute all scores → (MarketStateVector, RegimeResult)."""
from config import config
from scoring.price_structure import PriceStructureScorer
from scoring.breadth_scorer import BreadthScorer
from scoring.oi_matrix import OIMatrixScorer
from scoring.volatility_regime import VolatilityRegimeScorer
from regime_detector import RegimeDetector
from models import MarketStateVector
ps = PriceStructureScorer().compute(target)
br = BreadthScorer().compute(target)
oi = OIMatrixScorer().compute(target)
vol = VolatilityRegimeScorer().compute(target)
detector = RegimeDetector()
detector.load_state(config.db_path)
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, target)
state = MarketStateVector(
date=target, regime=r.regime, regime_confidence=r.confidence,
regime_version=r.regime_version, regime_maturity_score=r.maturity_score,
breadth_top20=br.breadth_top20, breadth_top30=br.breadth_top30,
breadth_top50=br.breadth_top50, breadth_bucket=br.breadth_bucket,
breadth_divergence=br.breadth_divergence,
oi_state=oi.oi_state, volatility_regime=vol.vol_regime,
price_structure_score=ps, breadth_score=br,
oi_matrix_score=oi, volatility_regime_score=vol,
)
state.market_state_hash = state.compute_hash()
# Persist regime to DB so subsequent calls have correct state
from database import get_connection
conn = get_connection()
conn.execute("""
INSERT OR REPLACE INTO regime_history
(date, regime, confidence, regime_version, maturity_score, all_scores_json,
prior_regime, confirmation_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
str(target), r.regime.value, r.confidence, r.regime_version,
r.maturity_score, json.dumps(r.all_scores),
r.prior_regime.value if r.prior_regime else None,
r.confirmation_days,
))
conn.commit()
conn.close()
return state, r
def cmd_fetch(args):
"""Fetch raw data and store to DB."""
from database import init_db
from fetchers.ohlcv import OHLCVFetcher
from fetchers.breadth import BreadthFetcher
target = parse_date(args.date) if args.date else Date.today()
init_db()
module = args.module or "all"
if module in ("ohlcv", "all"):
logger.info(f"Fetching OHLCV for {target}...")
fetcher = OHLCVFetcher()
df = fetcher.fetch(target)
if not df.empty:
n = fetcher.store_df(df)
logger.info(f"OHLCV: stored {n} rows")
if module in ("breadth", "all"):
logger.info(f"Fetching Breadth for {target}...")
fetcher = BreadthFetcher()
record = fetcher.fetch(target)
if record:
fetcher.store(record=record)
logger.info(f"Breadth: stored (adv={record.get('advance_top50')}, "
f"dec={record.get('decline_top50')}, "
f"ema20={record.get('above_ema20_top50')})")
if module in ("derivatives", "all"):
logger.info(f"Fetching Derivatives for {target}...")
from fetchers.derivatives import DerivativesFetcher
fetcher = DerivativesFetcher()
records = fetcher.fetch(target)
if records:
n = fetcher.store(records=records)
logger.info(f"Derivatives: stored {n} records")
def cmd_score(args):
"""Compute all factor scores and regime for a date."""
from database import init_db
target = parse_date(args.date) if args.date else Date.today()
init_db()
logger.info(f"Computing scores for {target}...")
state, _ = _build_market_state(target)
# Output
ps = state.price_structure_score
br = state.breadth_score
oi = state.oi_matrix_score
vol = state.volatility_regime_score
print(f"\n{'='*60}")
print(f" {target} Market State")
print(f"{'='*60}")
print(f" Regime: {state.regime.value} (conf={state.regime_confidence:.2f}, "
f"v={state.regime_version})")
print(f" Maturity: {state.regime_maturity_score:.0f}/100")
print(f" Breadth: {state.breadth_bucket.value} "
f"(T20={state.breadth_top20:.0f} T30={state.breadth_top30:.0f} "
f"T50={state.breadth_top50:.0f} div={state.breadth_divergence:+.0f})")
print(f" OI State: {state.oi_state.value}")
print(f" Volatility: {state.volatility_regime.value}")
print(f"{'='*60}")
print(f" Scores:")
print(f" Price Structure: {ps.score:.0f} {ps.label}")
print(f" Breadth: {br.score:.0f} {br.breadth_bucket.value}")
print(f" OI Matrix: {oi.score:.0f} {oi.oi_state.value}")
print(f" Volatility: {vol.score:.0f} {vol.vol_regime.value}")
print(f"{'='*60}")
print(f" Market State Hash: {state.market_state_hash}")
print()
return state
def cmd_regime(args):
"""Show regime history."""
from database import get_connection
days = args.days or 30
conn = get_connection()
rows = conn.execute(
"SELECT date, regime, confidence, maturity_score, confirmation_days "
"FROM regime_history ORDER BY date DESC LIMIT ?",
(days,)
).fetchall()
conn.close()
print(f"\n{'='*50}")
print(f" Regime History (last {days} days)")
print(f"{'='*50}")
for r in rows:
print(f" {r['date']} {r['regime']:7s} conf={r['confidence']:.2f} "
f"mat={r['maturity_score']:.0f} days={r['confirmation_days']}")
print()
def cmd_track(args):
"""Record a trading signal with current market state."""
from database import init_db
from expectancy.tracker import SignalTracker
target = parse_date(args.date) if args.date else Date.today()
init_db()
logger.info(f"Recording {args.signal} on {target} @ {args.price}")
state, _ = _build_market_state(target)
tracker = SignalTracker()
rid = tracker.record(
date=target, signal_type=args.signal, entry_price=args.price,
state=state, signal_grade=args.grade, signal_strength=args.strength,
)
logger.info(f"Signal recorded: id={rid}")
def cmd_backfill(args):
"""Backfill historical breadth + regime scores."""
from datetime import date as Date, timedelta
from database import init_db, get_connection
from fetchers.ohlcv import OHLCVFetcher
from fetchers.breadth import BreadthFetcher
from config import config
import pandas as pd
import requests
start = parse_date(args.from_date)
end = parse_date(args.to_date) if args.to_date else Date.today()
init_db()
# Step 1: Ensure OHLCV data exists for the range
logger.info(f"Step 1/3: Fetching BTC OHLCV...")
OHLCVFetcher().store_df(OHLCVFetcher().fetch())
# Step 2: Backfill breadth — fetch TOP50 daily data and compute per date
logger.info(f"Step 2/3: Backfilling breadth {start}{end}...")
provider_url = config.provider_url
all_symbol_data = {}
for sym in config.top50_symbols:
try:
df = pd.DataFrame(requests.get(
f"{provider_url}/api/candles",
params={"symbol": sym, "tf": "1d", "limit": 400},
timeout=30
).json())
if not df.empty and "timestamp" in df.columns:
df["date"] = pd.to_datetime(df["timestamp"], unit="ms").dt.date
df["close"] = df["close"].astype(float)
df["high"] = df["high"].astype(float)
df["ema20"] = df["close"].ewm(20).mean()
all_symbol_data[sym] = df
except Exception as e:
logger.debug(f" Skip {sym}: {e}")
logger.info(f" Fetched {len(all_symbol_data)}/{len(config.top50_symbols)} symbols")
# Compute breadth for each date
conn = get_connection()
current = start
breadth_count = 0
while current <= end:
target_str = str(current)
try:
advances_50 = declines_50 = above_ema20_50 = new_highs_50 = 0
advances_30 = advances_20 = above_ema20_30 = above_ema20_20 = 0
new_highs_30 = new_highs_20 = 0
for rank, (sym, df) in enumerate(all_symbol_data.items()):
rows = df[df["date"] == current]
if rows.empty:
continue
row = rows.iloc[0]
prev_rows = df[df["date"] < current]
if prev_rows.empty:
continue
prev = prev_rows.iloc[-1]
if row["close"] > prev["close"]:
if rank < 50: advances_50 += 1
if rank < 30: advances_30 += 1
if rank < 20: advances_20 += 1
elif row["close"] < prev["close"]:
if rank < 50: declines_50 += 1
if not pd.isna(row.get("ema20")) and row["close"] > row["ema20"]:
if rank < 50: above_ema20_50 += 1
if rank < 30: above_ema20_30 += 1
if rank < 20: above_ema20_20 += 1
recent_highs = df[(df["date"] < current) & (df["date"] >= current - timedelta(days=20))]
if not recent_highs.empty and row["high"] > recent_highs["high"].max():
if rank < 50: new_highs_50 += 1
if rank < 30: new_highs_30 += 1
if rank < 20: new_highs_20 += 1
conn.execute("""INSERT OR REPLACE INTO breadth_daily
(date, total_tracked, advance_top50, decline_top50, above_ema20_top50,
new_highs_20d_top50, advance_top30, advance_top20,
above_ema20_top30, above_ema20_top20, new_highs_20d_top30, new_highs_20d_top20)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(target_str, len(all_symbol_data),
advances_50, declines_50, above_ema20_50, new_highs_50,
advances_30, advances_20, above_ema20_30, above_ema20_20,
new_highs_30, new_highs_20))
breadth_count += 1
except Exception as e:
logger.debug(f" Breadth skip {current}: {e}")
current += timedelta(days=1)
conn.commit()
logger.info(f" Breadth backfill: {breadth_count} days")
# Step 3: Compute regime scores for each date
logger.info(f"Step 3/3: Computing regime scores {start}{end}...")
from scoring.price_structure import PriceStructureScorer
from scoring.breadth_scorer import BreadthScorer
from scoring.oi_matrix import OIMatrixScorer
from scoring.volatility_regime import VolatilityRegimeScorer
from regime_detector import RegimeDetector
detector = RegimeDetector()
current = start
score_count = 0
while current <= end:
try:
ps = PriceStructureScorer().compute(current)
br = BreadthScorer().compute(current)
if br.score == 50.0 and br.label == "No Data":
current += timedelta(days=1)
continue
oi = OIMatrixScorer().compute(current)
vol = VolatilityRegimeScorer().compute(current)
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, current)
conn.execute("""INSERT OR REPLACE INTO regime_history
(date, regime, confidence, regime_version, maturity_score,
all_scores_json, confirmation_days)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(str(current), r.regime.value, r.confidence, r.regime_version,
r.maturity_score, json.dumps(r.all_scores), r.confirmation_days))
score_count += 1
if score_count % 30 == 0:
conn.commit()
logger.info(f" Scored {score_count} days... ({current})")
except Exception as e:
logger.debug(f" Score skip {current}: {e}")
current += timedelta(days=1)
conn.commit()
conn.close()
logger.info(f"Backfill complete: {breadth_count} breadth + {score_count} regime days")
def cmd_expectancy(args):
"""Query signal expectancy for current market state."""
from database import init_db
from expectancy.engine import BayesianExpectancyEngine
target = parse_date(args.date) if args.date else Date.today()
init_db()
state, _ = _build_market_state(target)
engine = BayesianExpectancyEngine()
signal = args.signal or "B3"
report = engine.estimate(state, signal_type=signal, target_date=target)
print(f"\n{'='*60}")
print(f" {target} Signal Expectancy: {signal}")
print(f"{'='*60}")
print(f" Regime: {state.regime.value} (conf={state.regime_confidence:.2f})")
print(f" Breadth: {state.breadth_bucket.value} (T50={state.breadth_top50:.0f})")
print(f" OI State: {state.oi_state.value}")
print(f" Volatility: {state.volatility_regime.value}")
print(f"{'='*60}")
for layer in report.layers:
print(f" {layer.name:15s} N={layer.samples:4d} eff={layer.effective_samples:.0f} "
f"raw={layer.raw_winrate or 0:.1%} post={layer.posterior_winrate:.1%} "
f"ret={layer.avg_return or 0:+.1f}%")
print(f"{'='*60}")
print(f" Final: {report.final_estimate:.1%} "
f"(sufficiency={report.sufficiency.value}, source={report.source})")
if report.profit_factor:
print(f" PF={report.profit_factor} MAE={report.max_adverse_excursion}%")
print()
def main():
parser = argparse.ArgumentParser(
description="ChanMacro — Crypto Market Memory System"
)
sub = parser.add_subparsers(dest="command", help="Commands")
# fetch
p_fetch = sub.add_parser("fetch", help="Fetch raw data")
p_fetch.add_argument("--date", help="Target date (YYYY-MM-DD)")
p_fetch.add_argument("--module", choices=["ohlcv", "breadth", "derivatives", "all"])
# score
p_score = sub.add_parser("score", help="Compute scores and regime")
p_score.add_argument("--date", help="Target date (YYYY-MM-DD)")
# regime
p_regime = sub.add_parser("regime", help="Show regime history")
p_regime.add_argument("--days", type=int, default=30)
# track
p_track = sub.add_parser("track", help="Record a trading signal")
p_track.add_argument("--date", help="Signal date (YYYY-MM-DD)")
p_track.add_argument("--signal", required=True, help="Signal type (B1/B2/B3/S1/S2/S3)")
p_track.add_argument("--price", type=float, required=True, help="Entry price")
p_track.add_argument("--grade", choices=["A", "B", "C"], help="Signal quality grade")
p_track.add_argument("--strength", type=float, help="Signal strength 0-100")
# backfill
p_backfill = sub.add_parser("backfill", help="Backfill historical scores")
p_backfill.add_argument("--from", dest="from_date", required=True)
p_backfill.add_argument("--to", dest="to_date")
# expectancy
p_expectancy = sub.add_parser("expectancy", help="Query signal expectancy")
p_expectancy.add_argument("--date", help="Target date (YYYY-MM-DD)")
p_expectancy.add_argument("--signal", default="B3", help="Signal type")
# validate
p_validate = sub.add_parser("validate", help="Run validation framework")
# cron
p_cron = sub.add_parser("cron", help="Run scheduled fetch+score loop")
# detect (Chan BSP signals)
p_detect = sub.add_parser("detect", help="Detect Chan BSP signals and populate signal_features")
p_detect.add_argument("--from", dest="from_date", default="2024-01-01")
p_detect.add_argument("--to", dest="to_date")
# serve
p_serve = sub.add_parser("serve", help="Start web dashboard")
args = parser.parse_args()
if args.command == "fetch":
cmd_fetch(args)
elif args.command == "score":
cmd_score(args)
elif args.command == "regime":
cmd_regime(args)
elif args.command == "track":
cmd_track(args)
elif args.command == "backfill":
cmd_backfill(args)
elif args.command == "expectancy":
cmd_expectancy(args)
elif args.command == "validate":
from validation.reporter import ValidationReporter
report = ValidationReporter().run_all()
print(report)
elif args.command == "detect":
from chan_integration import ChanSignalDetector
start = args.from_date
end = args.to_date or Date.today().isoformat()
detector = ChanSignalDetector()
count = detector.populate_signal_features(start, end)
logger.info(f"写入 {count} 条信号记录")
elif args.command == "serve":
from scheduler import get_scheduler
get_scheduler().start()
logger.info("启动 Web Dashboard: http://127.0.0.1:8124")
from web.app import app
app.run(host="0.0.0.0", port=8124, debug=False)
elif args.command == "cron":
from scheduler import get_scheduler
logger.info("启动后台调度器 (Ctrl+C 停止)")
s = get_scheduler()
s.start()
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
s.stop()
logger.info("调度器已停止")
else:
parser.print_help()
if __name__ == "__main__":
main()
-23
View File
@@ -1,23 +0,0 @@
{
"provider_url": "https://provider.jackyu66.com",
"db_path": "data/macro.db",
"btc_symbol": "BTC/USDT:USDT",
"regime_version": "v1_price_breadth_vol",
"half_life_days": 180,
"sufficiency_min_effective": 30,
"sufficiency_low": 50,
"sufficiency_medium": 100,
"level_min_samples": 50,
"knn_max_distance": 0.35,
"knn_k": 200,
"oi_price_threshold_pct": 0.5,
"oi_oi_threshold_pct": 0.5,
"vol_low_threshold": 2.0,
"vol_high_threshold": 5.0,
"vol_explosive_threshold": 10.0,
"regime_w_price": 0.35,
"regime_w_breadth": 0.50,
"regime_w_vol": 0.15,
"trend_w_price": 0.30,
"trend_w_breadth": 0.70
}
-113
View File
@@ -1,113 +0,0 @@
"""
config.py — Global configuration for ChanMacro.
All weights, thresholds, and paths are configurable.
V1 weights are deliberately simple; they will be tuned via Phase 0 validation.
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@dataclass
class Config:
"""Global configuration. Override via config.json or env vars."""
# ── Paths ──────────────────────────────────────────────
db_path: str = "data/macro.db"
data_dir: str = "data"
# ── Data Provider ──────────────────────────────────────
provider_url: str = "https://provider.jackyu66.com"
btc_symbol: str = "BTC/USDT:USDT"
top50_symbols: list[str] = field(default_factory=lambda: [
"BTC/USDT:USDT", "ETH/USDT:USDT", "SOL/USDT:USDT",
"BNB/USDT:USDT", "XRP/USDT:USDT", "DOGE/USDT:USDT",
"SUI/USDT:USDT", "TON/USDT:USDT", "ZEC/USDT:USDT",
"1000PEPE/USDT:USDT", "SAGA/USDT:USDT",
"XAU/USDT:USDT", "XAG/USDT:USDT",
"CL/USDT:USDT", "BILL/USDT:USDT", "BZ/USDT:USDT",
"LAB/USDT:USDT", "CRCL/USDT:USDT", "SNDK/USDT:USDT",
"CHIP/USDT:USDT",
])
# ── Breadth ────────────────────────────────────────────
breadth_top_n: list[int] = field(default_factory=lambda: [20, 30, 50])
breadth_ema_period: int = 20
breadth_new_high_window: int = 20
# ── Regime (factor-locked: Price + Breadth + Vol) ─────
regime_version: str = "v1_price_breadth_vol"
# Weights for trend_score within regime detection
regime_w_price: float = 0.35
regime_w_breadth: float = 0.50
regime_w_vol: float = 0.15
# Weights for panic_score
regime_panic_w_anti_trend: float = 0.60
regime_panic_w_vol_extreme: float = 0.40
# ── Price Structure ────────────────────────────────────
ps_ema_fast: int = 20
ps_ema_mid: int = 60
ps_ema_slow: int = 120
ps_adx_period: int = 14
ps_adx_threshold: int = 25
ps_atr_period: int = 14
ps_bb_period: int = 20
ps_roc_periods: list[int] = field(default_factory=lambda: [5, 10, 20])
# ── OI Matrix ──────────────────────────────────────────
oi_price_threshold_pct: float = 0.5 # min price change% to classify
oi_oi_threshold_pct: float = 0.5 # min OI change% to classify
# ── Volatility Regime ──────────────────────────────────
vol_atr_period: int = 14
vol_hv_short: int = 20
vol_hv_long: int = 60
# Thresholds (ATR/Close %)
vol_low_threshold: float = 2.0
vol_high_threshold: float = 5.0
vol_explosive_threshold: float = 10.0
# ── Trend (L2 aggregation) ─────────────────────────────
trend_w_price: float = 0.30
trend_w_breadth: float = 0.70
# ── Maturity Score ─────────────────────────────────────
maturity_w_trend: float = 0.50
maturity_w_breadth: float = 0.30
maturity_w_vol: float = 0.20
# ── Expectancy ─────────────────────────────────────────
half_life_days: int = 180
sufficiency_min_effective: int = 30
sufficiency_low: int = 50
sufficiency_medium: int = 100
level_min_samples: int = 50
knn_max_distance: float = 0.35
knn_k: int = 200
# ── Validation ─────────────────────────────────────────
min_history_days: int = 365
regime_min_avg_duration: int = 5
regime_max_flip_rate: float = 0.15
@classmethod
def from_json(cls, path: str = "config.json") -> "Config":
"""Load config from JSON file, overriding defaults."""
import json
config = cls()
try:
with open(path) as f:
data = json.load(f)
for key, value in data.items():
if hasattr(config, key):
setattr(config, key, value)
except FileNotFoundError:
pass
return config
# Global singleton
config = Config()
-224
View File
@@ -1,224 +0,0 @@
"""
database.py — SQLite schema initialization and connection management.
"""
import sqlite3
import os
from pathlib import Path
SCHEMA = """
-- ═══════════════════════════════════════════════
-- L0: Raw data tables
-- ═══════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS ohlcv_daily (
date TEXT NOT NULL,
symbol TEXT NOT NULL DEFAULT 'BTC/USDT:USDT',
open REAL,
high REAL,
low REAL,
close REAL,
volume REAL,
ema20 REAL,
ema60 REAL,
ema120 REAL,
atr_14 REAL,
bb_width REAL,
adx_14 REAL,
PRIMARY KEY (date, symbol)
);
CREATE TABLE IF NOT EXISTS breadth_daily (
date TEXT PRIMARY KEY,
total_tracked INTEGER DEFAULT 50,
advance_top50 INTEGER DEFAULT 0,
decline_top50 INTEGER DEFAULT 0,
above_ema20_top50 INTEGER DEFAULT 0,
new_highs_20d_top50 INTEGER DEFAULT 0,
btc_dominance REAL,
advance_top20 INTEGER DEFAULT 0,
advance_top30 INTEGER DEFAULT 0,
above_ema20_top20 INTEGER DEFAULT 0,
above_ema20_top30 INTEGER DEFAULT 0,
new_highs_20d_top20 INTEGER DEFAULT 0,
new_highs_20d_top30 INTEGER DEFAULT 0,
fetched_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS derivatives (
date TEXT NOT NULL,
symbol TEXT NOT NULL DEFAULT 'BTC/USDT:USDT',
funding_rate REAL,
open_interest REAL,
oi_24h_change_pct REAL,
long_liquidations REAL,
short_liquidations REAL,
basis_annualised_pct REAL,
source TEXT DEFAULT 'binance',
fetched_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (date, symbol)
);
CREATE TABLE IF NOT EXISTS etf_flow (
date TEXT NOT NULL,
product TEXT NOT NULL,
net_flow_million REAL NOT NULL,
price REAL,
source TEXT DEFAULT 'farside',
fetched_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (date, product)
);
CREATE TABLE IF NOT EXISTS stablecoin_supply (
date TEXT NOT NULL,
token TEXT NOT NULL,
chain TEXT NOT NULL DEFAULT 'all',
supply REAL NOT NULL,
source TEXT DEFAULT 'defillama',
fetched_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (date, token, chain)
);
-- ═══════════════════════════════════════════════
-- L3: Regime history
-- ═══════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS regime_history (
date TEXT PRIMARY KEY,
regime TEXT NOT NULL,
confidence REAL,
regime_version TEXT NOT NULL DEFAULT 'v1_price_breadth_vol',
maturity_score REAL DEFAULT 50.0,
all_scores_json TEXT DEFAULT '{}',
prior_regime TEXT,
confirmation_days INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
-- ═══════════════════════════════════════════════
-- ★ signal_features — THE moat
-- ═══════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS signal_features (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
signal_type TEXT NOT NULL,
signal_version TEXT NOT NULL DEFAULT 'b3_v1',
symbol TEXT DEFAULT 'BTC/USDT:USDT',
-- ★★ Version control (most important fields)
regime_version TEXT NOT NULL DEFAULT 'v1_price_breadth_vol',
signal_grade TEXT,
signal_strength REAL,
-- Market State Vector snapshot
regime TEXT NOT NULL,
regime_confidence REAL,
regime_maturity_score REAL DEFAULT 50.0,
market_state_hash TEXT,
state_embedding TEXT DEFAULT '[]',
breadth_top20 REAL,
breadth_top30 REAL,
breadth_top50 REAL,
breadth_bucket TEXT,
breadth_divergence REAL,
oi_state TEXT,
volatility_regime TEXT,
price_structure_score REAL,
-- Chan context (V5+)
chan_trend_direction TEXT,
chan_pivot_count INTEGER,
chan_divergence_type TEXT,
-- Outcomes
entry_price REAL,
result_1d REAL,
result_3d REAL,
result_5d REAL,
result_7d REAL,
result_14d REAL,
max_favorable_excursion REAL,
max_adverse_excursion REAL,
is_win_7d INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_sf_regime ON signal_features(regime);
CREATE INDEX IF NOT EXISTS idx_sf_signal ON signal_features(signal_type);
CREATE INDEX IF NOT EXISTS idx_sf_oi_state ON signal_features(oi_state);
CREATE INDEX IF NOT EXISTS idx_sf_date ON signal_features(date);
CREATE INDEX IF NOT EXISTS idx_sf_state_hash ON signal_features(market_state_hash);
CREATE INDEX IF NOT EXISTS idx_sf_regime_version ON signal_features(regime_version);
CREATE INDEX IF NOT EXISTS idx_sf_signal_version ON signal_features(signal_version);
-- ═══════════════════════════════════════════════
-- Expectancy cache (raw counts, NOT posteriors)
-- ═══════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS expectancy_cache (
state_hash TEXT NOT NULL,
signal_type TEXT NOT NULL,
wins_weighted REAL DEFAULT 0,
losses_weighted REAL DEFAULT 0,
sum_return_7d REAL DEFAULT 0,
sum_return_sq_7d REAL DEFAULT 0,
effective_samples REAL DEFAULT 0,
sufficiency TEXT DEFAULT 'INSUFFICIENT',
updated_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (state_hash, signal_type)
);
-- ═══════════════════════════════════════════════
-- Similarity outcome (KNN weight learning, Phase D)
-- ═══════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS similarity_outcome (
id INTEGER PRIMARY KEY AUTOINCREMENT,
state_a_hash TEXT,
state_b_hash TEXT,
distance REAL,
actual_return_gap REAL,
dimension_weights_json TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now'))
);
-- ═══════════════════════════════════════════════
-- chan_context — Chan theory integration (V1 empty)
-- ═══════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS chan_context (
date TEXT NOT NULL,
timeframe TEXT NOT NULL DEFAULT '1d',
trend_direction TEXT,
trend_strength REAL,
pivot_count INTEGER,
pivot_level TEXT,
signal_type TEXT,
signal_strength REAL,
divergence_type TEXT,
chan_structure_score REAL,
alignment_score REAL,
raw_context_json TEXT DEFAULT '{}',
PRIMARY KEY (date, timeframe)
);
"""
def init_db(db_path: str = "data/macro.db") -> sqlite3.Connection:
"""Initialize database: create directory and all tables."""
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.executescript(SCHEMA)
conn.commit()
return conn
def get_connection(db_path: str = "data/macro.db") -> sqlite3.Connection:
"""Get a database connection. Creates tables if first run."""
if not os.path.exists(db_path):
return init_db(db_path)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
-4
View File
@@ -1,4 +0,0 @@
"""Expectancy Engine — Signal tracking, Bayesian inference, time decay."""
from .tracker import SignalTracker
from .decay import TimeDecay
from .engine import BayesianExpectancyEngine, SufficiencyGuard
-55
View File
@@ -1,55 +0,0 @@
"""
expectancy/decay.py — Time-weighted sample decay.
2024 market structure ≠ 2026 market structure.
Recent samples get higher weight via exponential decay.
"""
from datetime import date as Date
from typing import Optional
import numpy as np
class TimeDecay:
"""Exponential time decay for sample weighting."""
def __init__(self, half_life_days: int = 180):
self.half_life = half_life_days
self._decay_rate = np.log(2) / half_life_days
def weight(self, sample_date: Date, reference_date: Optional[Date] = None) -> float:
"""
Compute decay weight for a sample.
weight = exp(-days_ago * decay_rate)
"""
if reference_date is None:
reference_date = Date.today()
days = (reference_date - sample_date).days
return np.exp(-days * self._decay_rate)
def weights(self, dates: list[Date], reference_date: Optional[Date] = None) -> np.ndarray:
"""Compute decay weights for a list of dates."""
return np.array([self.weight(d, reference_date) for d in dates])
def weighted_win_rate(self, wins: np.ndarray, weights: np.ndarray) -> float:
"""Weighted win rate: sum(wins * weights) / sum(weights)."""
total_weight = weights.sum()
if total_weight == 0:
return 0.0
return float((wins * weights).sum() / total_weight)
def weighted_mean(self, values: np.ndarray, weights: np.ndarray) -> float:
"""Weighted mean."""
total_weight = weights.sum()
if total_weight == 0:
return 0.0
return float((values * weights).sum() / total_weight)
def effective_samples(self, weights: np.ndarray) -> float:
"""Effective number of samples after decay weighting."""
return float(weights.sum())
@staticmethod
def weight_at_age(days_ago: int, half_life_days: int = 180) -> float:
"""Quick weight lookup for a given age in days."""
return np.exp(-days_ago * np.log(2) / half_life_days)
-295
View File
@@ -1,295 +0,0 @@
"""
expectancy/engine.py — Bayesian Expectancy Engine.
Core algorithm:
1. LeveledExpectancy: filter layer-by-layer, stop at highest valid level
2. Empirical Bayes prior: prior = signal's global historical winrate
3. Dynamic Beta strength: adaptive to sample size
4. Time decay: recent samples weighted higher (half_life=180d)
5. SufficiencyGuard: refuse output if effective_samples < 30
6. KNN Fallback: similarity search when strict filtering fails (Phase D)
"""
from datetime import date as Date
from typing import Optional
import sqlite3
import logging
import numpy as np
import pandas as pd
from models import (
MarketStateVector, ExpectancyReport, ExpectancyLayer,
SufficiencyLevel, MarketRegime,
)
from config import config
from .decay import TimeDecay
logger = logging.getLogger(__name__)
class SufficiencyGuard:
"""Prevents trading advice from insufficient samples."""
def __init__(self, min_effective: int = 30, low: int = 50, medium: int = 100):
self.MIN = min_effective
self.LOW = low
self.MEDIUM = medium
def evaluate(self, effective_samples: float) -> SufficiencyLevel:
if effective_samples < self.MIN:
return SufficiencyLevel.INSUFFICIENT
elif effective_samples < self.LOW:
return SufficiencyLevel.LOW
elif effective_samples < self.MEDIUM:
return SufficiencyLevel.MEDIUM
return SufficiencyLevel.HIGH
class BayesianExpectancyEngine:
"""
Leveled Bayesian Expectancy Engine.
Query layers from coarse to fine. Stop when effective_samples drops below threshold.
Uses Empirical Bayes prior (signal's global winrate, not fixed 50%).
"""
# Expectancy query levels: name → WHERE clause template
LEVELS = [
("Base", "signal_type = '{signal}'"),
("+ Regime", "signal_type = '{signal}' AND regime = '{regime}'"),
("+ Breadth", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}'"),
("+ OI State", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}' AND oi_state = '{oi}'"),
("+ Volatility", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}' AND oi_state = '{oi}' AND volatility_regime = '{vol}'"),
]
def __init__(self, db_path: Optional[str] = None,
half_life_days: int = 180,
level_min_samples: int = 50):
self.db_path = db_path or config.db_path
self.decay = TimeDecay(half_life_days)
self.guard = SufficiencyGuard(
min_effective=config.sufficiency_min_effective,
low=config.sufficiency_low,
medium=config.sufficiency_medium,
)
self.level_min = level_min_samples
def estimate(self, state: MarketStateVector,
signal_type: str = "B3",
target_date: Optional[Date] = None) -> ExpectancyReport:
"""
Compute layered Bayesian expectancy for a signal in current market state.
Returns the estimate at the deepest level with >= level_min effective samples.
"""
if target_date is None:
target_date = Date.today()
conn = sqlite3.connect(self.db_path)
# Get global signal winrate for Empirical Bayes prior
global_rate = self._global_winrate(conn, signal_type)
layers = []
best_result = None
for level_name, template in self.LEVELS:
where = template.format(
signal=signal_type,
regime=state.regime.value,
breadth=state.breadth_bucket.value,
oi=state.oi_state.value,
vol=state.volatility_regime.value,
)
query = f"SELECT * FROM signal_features WHERE {where}"
df = pd.read_sql_query(query, conn)
if df.empty:
layers.append(ExpectancyLayer(
name=level_name, posterior_winrate=0.0,
samples=0, effective_samples=0.0,
))
continue
# Time-weighted stats
dates_list = [Date.fromisoformat(d) for d in df["date"]]
weights = self.decay.weights(dates_list, target_date)
eff_n = self.decay.effective_samples(weights)
wins = pd.to_numeric(df["is_win_7d"].fillna(0), errors="coerce").fillna(0).values
returns = pd.to_numeric(df["result_7d"].fillna(0), errors="coerce").fillna(0).values
raw_wr = float(wins.mean()) if len(wins) > 0 else 0.0
weighted_wr = self.decay.weighted_win_rate(wins, weights)
weighted_ret = self.decay.weighted_mean(returns, weights)
# Empirical Bayes posterior
posterior = self._bayesian_posterior(
global_rate=global_rate,
wins=wins.sum(),
samples=len(df),
)
layer = ExpectancyLayer(
name=level_name,
posterior_winrate=round(posterior, 4),
raw_winrate=round(raw_wr, 4),
samples=len(df),
effective_samples=round(eff_n, 1),
avg_return=round(weighted_ret, 2),
)
layers.append(layer)
# Level-based fallback: keep going while samples sufficient
if eff_n >= self.level_min:
best_result = layer
conn.close()
if best_result is None and layers:
# Fallback to the deepest layer that had any samples
for layer in reversed(layers):
if layer.samples > 0:
best_result = layer
break
if best_result is None:
return ExpectancyReport(
signal_type=signal_type,
date=target_date,
layers=layers,
final_estimate=0.0,
sufficiency=SufficiencyLevel.INSUFFICIENT,
source="insufficient",
)
sufficiency = self.guard.evaluate(
best_result.effective_samples
)
# Compute profit factor and MAE from the SAME level as best_result
profit_factor = None
avg_mae = None
if best_result and best_result.samples > 0:
# Re-query the level that produced best_result
best_level_idx = next(
i for i, l in enumerate(layers) if l.name == best_result.name
)
where = self.LEVELS[best_level_idx][1].format(
signal=signal_type, regime=state.regime.value,
breadth=state.breadth_bucket.value, oi=state.oi_state.value,
vol=state.volatility_regime.value,
)
query = f"SELECT result_7d, max_adverse_excursion FROM signal_features WHERE {where}"
conn2 = sqlite3.connect(self.db_path)
df_detail = pd.read_sql_query(query, conn2)
conn2.close()
if not df_detail.empty:
returns_7d = df_detail["result_7d"].dropna()
if len(returns_7d) > 0:
gains = returns_7d[returns_7d > 0].sum()
losses = abs(returns_7d[returns_7d < 0].sum())
profit_factor = round(gains / losses, 2) if losses > 0 else None
maes = df_detail["max_adverse_excursion"].dropna()
if len(maes) > 0:
avg_mae = round(float(maes.mean()), 2)
return ExpectancyReport(
signal_type=signal_type,
date=target_date,
layers=layers,
final_estimate=round(best_result.posterior_winrate, 4),
sufficiency=sufficiency,
prior_strength=self._prior_strength(best_result.samples),
half_life_days=self.decay.half_life,
source="bayesian",
avg_return_7d=best_result.avg_return,
profit_factor=profit_factor,
max_adverse_excursion=avg_mae,
)
def _global_winrate(self, conn: sqlite3.Connection,
signal_type: str) -> float:
"""Get global historical winrate for a signal type (Empirical Bayes prior)."""
row = conn.execute(
"SELECT AVG(is_win_7d) as wr, COUNT(*) as cnt "
"FROM signal_features WHERE signal_type = ? AND is_win_7d IS NOT NULL",
(signal_type,)
).fetchone()
if row and row[1] and row[1] > 0:
return float(row[0])
return 0.50 # default: neutral
def _prior_strength(self, samples: int) -> int:
"""Dynamic prior strength based on sample count."""
if samples < 100:
return 20 # Beta(10,10)
elif samples < 500:
return 40 # Beta(20,20)
else:
return 100 # Beta(50,50) — data dominates
def _bayesian_posterior(self, global_rate: float, wins: float,
samples: int) -> float:
"""
Empirical Bayes posterior: prior = global signal winrate.
posterior = (alpha + wins) / (alpha + beta + samples)
where alpha/(alpha+beta) = global_rate
"""
prior_strength = self._prior_strength(samples)
alpha = max(global_rate * prior_strength, 1.0) # floor at 1 to ensure shrinkage
beta = max((1 - global_rate) * prior_strength, 1.0)
return (alpha + wins) / (alpha + beta + samples)
def precompute_cache(self):
"""
Precompute expectancy for all state_hashes in signal_features.
Populates expectancy_cache table with raw weighted counts (not posteriors).
"""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
hashes = conn.execute(
"SELECT DISTINCT market_state_hash, signal_type FROM signal_features"
).fetchall()
today = Date.today()
count = 0
for row in hashes:
h = row["market_state_hash"]
sig = row["signal_type"]
df = pd.read_sql_query(
"SELECT date, is_win_7d, result_7d "
"FROM signal_features WHERE market_state_hash = ? AND signal_type = ?",
conn, params=(h, sig)
)
if df.empty:
continue
dates_list = [Date.fromisoformat(d) for d in df["date"]]
weights = self.decay.weights(dates_list, today)
wins_w = (df["is_win_7d"].fillna(0).values * weights).sum()
losses_w = ((1 - df["is_win_7d"].fillna(0)).values * weights).sum()
ret_sum = (df["result_7d"].fillna(0).values * weights).sum()
ret_sq = ((df["result_7d"].fillna(0).values ** 2) * weights).sum()
eff_n = weights.sum()
sufficiency = self.guard.evaluate(eff_n).value
conn.execute("""
INSERT OR REPLACE INTO expectancy_cache
(state_hash, signal_type, wins_weighted, losses_weighted,
sum_return_7d, sum_return_sq_7d, effective_samples, sufficiency)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (h, sig, wins_w, losses_w, ret_sum, ret_sq, eff_n, sufficiency))
count += 1
conn.commit()
conn.close()
logger.info(f"Precomputed expectancy cache: {count} state×signal combos")
return count
-271
View File
@@ -1,271 +0,0 @@
"""
expectancy/tracker.py — SignalTracker: records signals with full market state
and computes forward outcomes.
This is the entry point for populating signal_features — THE moat table.
"""
from datetime import date as Date, timedelta
from typing import Optional
import sqlite3
import json
import logging
import pandas as pd
import numpy as np
from models import (
MarketStateVector, SignalFeatureRecord, MarketRegime,
OIState, BreadthBucket, VolRegime, SignalGrade,
)
from config import config
logger = logging.getLogger(__name__)
class SignalTracker:
"""
Records trading signals with full market state context.
Usage:
tracker = SignalTracker()
tracker.record(
date=Date(2026, 6, 24),
signal_type="B3",
entry_price=96500.0,
state=market_state_vector, # from scoring pipeline
signal_grade="A",
)
"""
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path or config.db_path
def record(self, date: Date, signal_type: str, entry_price: float,
state: MarketStateVector,
signal_version: str = "b3_v1",
signal_grade: Optional[str] = None,
signal_strength: Optional[float] = None) -> int:
"""
Record a signal with market state snapshot and compute forward outcomes.
Returns the record ID in signal_features.
"""
conn = sqlite3.connect(self.db_path)
# Compute forward outcomes
outcomes = self._compute_outcomes(conn, date, entry_price)
# Build embedding
embedding = json.dumps(state.state_embedding())
record_id = conn.execute("""
INSERT INTO signal_features
(date, signal_type, signal_version, symbol,
regime_version, signal_grade, signal_strength,
regime, regime_confidence, regime_maturity_score,
market_state_hash, state_embedding,
breadth_top20, breadth_top30, breadth_top50,
breadth_bucket, breadth_divergence,
oi_state, volatility_regime, price_structure_score,
entry_price,
result_1d, result_3d, result_5d, result_7d, result_14d,
max_favorable_excursion, max_adverse_excursion,
is_win_7d)
VALUES (?, ?, ?, ?, ?, ?, ?,
?, ?, ?,
?, ?,
?, ?, ?,
?, ?,
?, ?, ?,
?,
?, ?, ?, ?, ?,
?, ?,
?)
""", (
str(date), signal_type, signal_version, state.symbol,
state.regime_version, signal_grade, signal_strength,
state.regime.value, state.regime_confidence, state.regime_maturity_score,
state.market_state_hash, embedding,
state.breadth_top20, state.breadth_top30, state.breadth_top50,
state.breadth_bucket.value, state.breadth_divergence,
state.oi_state.value, state.volatility_regime.value,
state.price_structure_score.score,
entry_price,
outcomes.get("result_1d"), outcomes.get("result_3d"),
outcomes.get("result_5d"), outcomes.get("result_7d"),
outcomes.get("result_14d"),
outcomes.get("mfe"), outcomes.get("mae"),
outcomes.get("is_win_7d"),
)).lastrowid
conn.commit()
conn.close()
is_win = outcomes.get("is_win_7d", 0)
ret_7d = outcomes.get("result_7d", 0) or 0
logger.info(
f"Recorded {signal_type} on {date} @ {entry_price:.0f} "
f"(regime={state.regime.value}, breadth={state.breadth_bucket.value}, "
f"oi={state.oi_state.value}) → 7d={ret_7d:+.1f}%"
)
return record_id
def _compute_outcomes(self, conn: sqlite3.Connection, date: Date,
entry_price: float) -> dict:
"""
Compute forward returns, MFE, MAE from OHLCV data.
Queries future daily bars relative to the signal date.
"""
# Get future OHLCV data
df = pd.read_sql_query(
"SELECT date, high, low, close FROM ohlcv_daily "
"WHERE date > ? AND symbol = 'BTC/USDT:USDT' "
"ORDER BY date ASC LIMIT 20",
conn, params=(str(date),)
)
if df.empty:
return {}
outcomes = {}
entry = entry_price
# Forward returns
for horizon_days, col in [(1, "result_1d"), (3, "result_3d"),
(5, "result_5d"), (7, "result_7d"),
(14, "result_14d")]:
if len(df) >= horizon_days:
exit_price = float(df.iloc[horizon_days - 1]["close"])
outcomes[col] = round((exit_price - entry) / entry * 100, 2)
# MFE / MAE
if len(df) > 0:
highs = df["high"].astype(float).values[:14]
lows = df["low"].astype(float).values[:14]
outcomes["mfe"] = round((max(highs) - entry) / entry * 100, 2)
outcomes["mae"] = round((min(lows) - entry) / entry * 100, 2)
# is_win_7d
outcomes["is_win_7d"] = 1 if outcomes.get("result_7d", 0) > 0 else 0
return outcomes
def backfill_signals(self, signals: list[dict]) -> int:
"""
Backfill multiple signals from historical data.
Each signal dict:
{"date": Date, "signal_type": str, "entry_price": float,
"signal_grade": str (optional), "signal_strength": float (optional)}
This requires the scoring pipeline to have been run for those dates
(breadth_daily, ohlcv_daily, derivatives all populated).
"""
from scoring.price_structure import PriceStructureScorer
from scoring.breadth_scorer import BreadthScorer
from scoring.oi_matrix import OIMatrixScorer
from scoring.volatility_regime import VolatilityRegimeScorer
from regime_detector import RegimeDetector
detector = RegimeDetector()
count = 0
for sig in signals:
target = sig["date"]
try:
# Compute market state for this date
ps = PriceStructureScorer(self.db_path).compute(target)
br = BreadthScorer(self.db_path).compute(target)
oi = OIMatrixScorer(self.db_path).compute(target)
vol = VolatilityRegimeScorer(self.db_path).compute(target)
regime_result = detector.detect(
price_structure_score=ps.score,
breadth_score=br.breadth_top50,
volatility_regime=vol.vol_regime.value,
date=target,
)
state = MarketStateVector(
date=target,
regime=regime_result.regime,
regime_confidence=regime_result.confidence,
regime_version=regime_result.regime_version,
regime_maturity_score=regime_result.maturity_score,
breadth_top20=br.breadth_top20,
breadth_top30=br.breadth_top30,
breadth_top50=br.breadth_top50,
breadth_bucket=br.breadth_bucket,
breadth_divergence=br.breadth_divergence,
oi_state=oi.oi_state,
volatility_regime=vol.vol_regime,
price_structure_score=ps,
breadth_score=br,
oi_matrix_score=oi,
volatility_regime_score=vol,
)
state.market_state_hash = state.compute_hash()
self.record(
date=target,
signal_type=sig["signal_type"],
entry_price=sig["entry_price"],
state=state,
signal_grade=sig.get("signal_grade"),
signal_strength=sig.get("signal_strength"),
)
count += 1
except Exception as e:
logger.warning(f"Failed to backfill {sig['signal_type']} on {target}: {e}")
return count
def get_samples(self, signal_type: Optional[str] = None,
regime: Optional[str] = None,
breadth_bucket: Optional[str] = None,
oi_state: Optional[str] = None,
volatility_regime: Optional[str] = None,
limit: int = 5000) -> list[dict]:
"""Query signal_features with optional filters."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
query = "SELECT * FROM signal_features WHERE 1=1"
params = []
if signal_type:
query += " AND signal_type = ?"
params.append(signal_type)
if regime:
query += " AND regime = ?"
params.append(regime)
if breadth_bucket:
query += " AND breadth_bucket = ?"
params.append(breadth_bucket)
if oi_state:
query += " AND oi_state = ?"
params.append(oi_state)
if volatility_regime:
query += " AND volatility_regime = ?"
params.append(volatility_regime)
query += " ORDER BY date DESC LIMIT ?"
params.append(limit)
rows = conn.execute(query, params).fetchall()
conn.close()
return [dict(r) for r in rows]
def count_samples(self) -> dict:
"""Count signal_features by signal_type and regime."""
conn = sqlite3.connect(self.db_path)
rows = conn.execute("""
SELECT signal_type, regime, COUNT(*) as cnt
FROM signal_features
GROUP BY signal_type, regime
ORDER BY signal_type, regime
""").fetchall()
conn.close()
return {f"{r[0]}/{r[1]}": r[2] for r in rows}
-5
View File
@@ -1,5 +0,0 @@
"""Data fetchers — L0 raw data acquisition."""
from .base import BaseFetcher
from .ohlcv import OHLCVFetcher
from .breadth import BreadthFetcher
from .derivatives import DerivativesFetcher
-69
View File
@@ -1,69 +0,0 @@
"""
fetchers/base.py — Abstract base class for all macro data fetchers.
Provides retry logic, rate limiting, and a common interface.
"""
from abc import ABC, abstractmethod
from datetime import date as Date
from typing import Optional
import logging
import time
import requests
class BaseFetcher(ABC):
"""Abstract base for all macro data fetchers."""
def __init__(self, timeout: int = 30, max_retries: int = 3):
self.timeout = timeout
self.max_retries = max_retries
self.logger = logging.getLogger(self.__class__.__name__)
def _get(self, url: str, params: Optional[dict] = None,
headers: Optional[dict] = None) -> dict:
"""GET with retry and exponential backoff."""
for attempt in range(self.max_retries):
try:
resp = requests.get(
url, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
return resp.json()
except requests.RequestException as e:
wait = 2 ** attempt
self.logger.warning(
f"Request failed (attempt {attempt+1}/{self.max_retries}): {e}. "
f"Retrying in {wait}s"
)
if attempt < self.max_retries - 1:
time.sleep(wait)
else:
raise
def _get_raw(self, url: str, params: Optional[dict] = None,
headers: Optional[dict] = None) -> bytes:
"""GET raw bytes with retry (for non-JSON endpoints)."""
for attempt in range(self.max_retries):
try:
resp = requests.get(
url, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
return resp.content
except requests.RequestException as e:
wait = 2 ** attempt
if attempt < self.max_retries - 1:
time.sleep(wait)
else:
raise
@abstractmethod
def fetch(self, target_date: Optional[Date] = None) -> list[dict]:
"""Fetch raw data. Returns list of record dicts."""
...
@abstractmethod
def store(self, db_path: str, records: list[dict]) -> int:
"""Store raw records into SQLite. Returns count of new rows."""
...
-189
View File
@@ -1,189 +0,0 @@
"""
fetchers/breadth.py — Fetches TOP50 OHLCV and computes market breadth metrics.
Multi-tier: Top20 / Top30 / Top50 for advance/decline, EMA20%, new highs, BTC.D.
"""
from datetime import date as Date, datetime
from typing import Optional
import logging
import pandas as pd
import numpy as np
import requests
from .base import BaseFetcher
from config import config
class BreadthFetcher(BaseFetcher):
"""Fetches TOP50 coin OHLCV data and computes breadth metrics."""
def __init__(self, provider_url: Optional[str] = None):
super().__init__(timeout=60, max_retries=3)
self.provider_url = provider_url or config.provider_url
self.symbols = config.top50_symbols
self.ema_period = config.breadth_ema_period
self.new_high_window = config.breadth_new_high_window
self.logger = logging.getLogger(__name__)
def fetch(self, target_date: Optional[Date] = None) -> dict:
"""
Fetch daily OHLCV for all TOP50 symbols and compute breadth.
Returns a dict suitable for storing in breadth_daily table.
"""
if target_date is None:
target_date = Date.today()
# Fetch last 60 days of daily data for each symbol to compute EMAs and new highs
all_data = {}
for symbol in self.symbols:
try:
df = self._fetch_symbol(symbol)
if df is not None and not df.empty:
all_data[symbol] = df
except Exception as e:
self.logger.debug(f"Failed to fetch {symbol}: {e}")
if not all_data:
self.logger.error("No symbol data fetched for breadth")
return {}
# Compute breadth metrics for the target date
breadth = self._compute_breadth(all_data, target_date)
return breadth
def _fetch_symbol(self, symbol: str) -> Optional[pd.DataFrame]:
"""Fetch daily OHLCV for a single symbol."""
url = f"{self.provider_url}/api/candles"
params = {
"symbol": symbol,
"tf": "1d",
"limit": 100,
}
try:
resp = requests.get(url, params=params, timeout=15)
resp.raise_for_status()
data = resp.json()
if not data:
return None
df = pd.DataFrame(data)
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df["date"] = df["timestamp"].dt.date
df = df.drop_duplicates(subset="date").sort_values("date").reset_index(drop=True)
df["close"] = df["close"].astype(float)
df["ema20"] = df["close"].ewm(span=self.ema_period, adjust=False).mean()
return df
except Exception:
return None
def _compute_breadth(self, all_data: dict, target_date: Date) -> dict:
"""Compute breadth metrics for a specific date across all symbols."""
total = len(all_data)
advances_50 = declines_50 = 0
above_ema20_50 = 0
new_highs_50 = 0
advances_30 = declines_30 = 0
above_ema20_30 = 0
new_highs_30 = 0
advances_20 = declines_20 = 0
above_ema20_20 = 0
new_highs_20 = 0
for i, (symbol, df) in enumerate(all_data.items()):
# Get data for target date
df["date_str"] = df["date"].astype(str)
target_str = str(target_date)
idx = df[df["date_str"] == target_str].index
if len(idx) == 0:
continue
row_idx = idx[0]
if row_idx < 1:
continue
current_close = df.loc[row_idx, "close"]
prev_close = df.loc[row_idx - 1, "close"]
# Advance/Decline
if current_close > prev_close:
if i < 50: advances_50 += 1
if i < 30: advances_30 += 1
if i < 20: advances_20 += 1
elif current_close < prev_close:
if i < 50: declines_50 += 1
if i < 30: declines_30 += 1
if i < 20: declines_20 += 1
# Above EMA20
ema20_val = df.loc[row_idx, "ema20"]
if not pd.isna(ema20_val) and current_close > ema20_val:
if i < 50: above_ema20_50 += 1
if i < 30: above_ema20_30 += 1
if i < 20: above_ema20_20 += 1
# New 20-day highs
lookback_start = max(0, row_idx - self.new_high_window)
recent_highs = df.loc[lookback_start:row_idx - 1, "high"].astype(float)
current_high = df.loc[row_idx, "high"]
if len(recent_highs) > 0 and float(current_high) > recent_highs.max():
if i < 50: new_highs_50 += 1
if i < 30: new_highs_30 += 1
if i < 20: new_highs_20 += 1
return {
"date": str(target_date),
"total_tracked": total,
"advance_top50": advances_50,
"decline_top50": declines_50,
"above_ema20_top50": above_ema20_50,
"new_highs_20d_top50": new_highs_50,
"advance_top30": advances_30,
"advance_top20": advances_20,
"above_ema20_top30": above_ema20_30,
"above_ema20_top20": above_ema20_20,
"new_highs_20d_top30": new_highs_30,
"new_highs_20d_top20": new_highs_20,
"btc_dominance": None, # Reserved for Coinglass API integration
}
def store(self, db_path: Optional[str] = None, record: Optional[dict] = None) -> int:
"""Store a breadth record into SQLite. Returns 1 if inserted/updated."""
import sqlite3
db_path = db_path or config.db_path
conn = sqlite3.connect(db_path)
if record is None:
conn.close()
return 0
try:
conn.execute("""
INSERT OR REPLACE INTO breadth_daily
(date, total_tracked,
advance_top50, decline_top50, above_ema20_top50, new_highs_20d_top50,
advance_top30, advance_top20,
above_ema20_top30, above_ema20_top20,
new_highs_20d_top30, new_highs_20d_top20,
btc_dominance)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
record["date"], record.get("total_tracked", 50),
record.get("advance_top50", 0), record.get("decline_top50", 0),
record.get("above_ema20_top50", 0), record.get("new_highs_20d_top50", 0),
record.get("advance_top30", 0), record.get("advance_top20", 0),
record.get("above_ema20_top30", 0), record.get("above_ema20_top20", 0),
record.get("new_highs_20d_top30", 0), record.get("new_highs_20d_top20", 0),
record.get("btc_dominance"),
))
conn.commit()
return 1
except Exception as e:
self.logger.error(f"Failed to store breadth: {e}")
return 0
finally:
conn.close()
-66
View File
@@ -1,66 +0,0 @@
"""
fetchers/derivatives.py — Fetches derivatives data from data_provider API.
Clean consumer: no direct ccxt dependency. Just HTTP GET /api/derivatives.
"""
from datetime import date as Date
from typing import Optional
import requests
from .base import BaseFetcher
from config import config
class DerivativesFetcher(BaseFetcher):
"""Fetches derivatives snapshot from data_provider /api/derivatives."""
def __init__(self, provider_url: Optional[str] = None):
super().__init__(timeout=15, max_retries=3)
self.provider_url = provider_url or config.provider_url
def fetch(self, target_date: Optional[Date] = None) -> list[dict]:
"""Fetch derivatives data. Returns list with one record dict."""
url = f"{self.provider_url}/api/derivatives"
params = {"symbol": config.btc_symbol}
try:
data = self._get(url, params=params)
record = {
"date": str(target_date or Date.today()),
"symbol": config.btc_symbol,
"funding_rate": data.get("funding_rate"),
"open_interest": data.get("open_interest"),
"oi_24h_change_pct": data.get("oi_change_pct"),
"basis_annualised_pct": data.get("basis"),
"source": "data_provider",
}
return [record]
except Exception:
return []
def store(self, db_path: Optional[str] = None, records: Optional[list[dict]] = None) -> int:
"""Store derivatives records into SQLite."""
import sqlite3
db_path = db_path or config.db_path
records = records or []
conn = sqlite3.connect(db_path)
count = 0
for r in records:
try:
conn.execute("""
INSERT OR REPLACE INTO derivatives
(date, symbol, funding_rate, open_interest, oi_24h_change_pct,
long_liquidations, short_liquidations, basis_annualised_pct)
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?)
""", (
r["date"], r.get("symbol", config.btc_symbol),
r.get("funding_rate"), r.get("open_interest"),
r.get("oi_24h_change_pct"), r.get("basis_annualised_pct"),
))
count += 1
except Exception:
continue
conn.commit()
conn.close()
return count
-157
View File
@@ -1,157 +0,0 @@
"""
fetchers/ohlcv.py — Fetches BTC daily OHLCV from the existing data_provider service.
Also pre-computes EMA20/60/120, ATR(14), BB width, ADX(14).
"""
from datetime import date as Date, datetime, timedelta
from typing import Optional
import logging
import pandas as pd
import numpy as np
import requests
from .base import BaseFetcher
from config import config
class OHLCVFetcher(BaseFetcher):
"""Fetches BTC daily K-line data from data_provider API."""
def __init__(self, provider_url: Optional[str] = None):
super().__init__(timeout=30, max_retries=3)
self.provider_url = provider_url or config.provider_url
self.symbol = config.btc_symbol
self.logger = logging.getLogger(__name__)
def fetch(self, target_date: Optional[Date] = None) -> pd.DataFrame:
"""
Fetch daily OHLCV for BTC. Returns DataFrame with computed indicators.
Fetches enough history (200 bars) to compute EMAs/ATR/BB/ADX accurately.
"""
url = f"{self.provider_url}/api/candles"
params = {
"symbol": self.symbol,
"tf": "1d",
"limit": 200,
}
resp = requests.get(url, params=params, timeout=self.timeout)
resp.raise_for_status()
data = resp.json()
if not data:
self.logger.warning("OHLCV API returned empty data")
return pd.DataFrame()
df = pd.DataFrame(data)
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df["date"] = df["timestamp"].dt.date
df = df.drop_duplicates(subset="date").sort_values("date").reset_index(drop=True)
# Rename columns to match expected format
df = df.rename(columns={
"open": "open", "high": "high", "low": "low", "close": "close",
"volume": "volume",
})
# Compute indicators
df = self._add_indicators(df)
return df
def _add_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
"""Add EMA, ATR, BB, ADX indicators."""
close = df["close"].astype(float)
high = df["high"].astype(float)
low = df["low"].astype(float)
# EMAs
df["ema20"] = close.ewm(span=20, adjust=False).mean()
df["ema60"] = close.ewm(span=60, adjust=False).mean()
df["ema120"] = close.ewm(span=120, adjust=False).mean()
# ATR(14)
tr1 = high - low
tr2 = (high - close.shift(1)).abs()
tr3 = (low - close.shift(1)).abs()
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
df["atr_14"] = tr.rolling(14).mean()
# Bollinger Bands width
sma20 = close.rolling(20).mean()
std20 = close.rolling(20).std()
df["bb_width"] = (2 * std20) / sma20 * 100 # as percentage
# ADX(14)
df["adx_14"] = self._compute_adx(df, period=14)
return df
@staticmethod
def _compute_adx(df: pd.DataFrame, period: int = 14) -> pd.Series:
"""Compute ADX from OHLC data."""
high = df["high"].astype(float)
low = df["low"].astype(float)
close = df["close"].astype(float)
plus_dm = high.diff()
minus_dm = low.diff().abs() * -1
plus_dm = plus_dm.where(plus_dm > 0, 0)
minus_dm = minus_dm.where(minus_dm < 0, 0).abs()
tr1 = high - low
tr2 = (high - close.shift(1)).abs()
tr3 = (low - close.shift(1)).abs()
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
atr = tr.rolling(period).mean()
plus_di = 100 * (plus_dm.rolling(period).mean() / atr)
minus_di = 100 * (minus_dm.rolling(period).mean() / atr)
dx = (abs(plus_di - minus_di) / (plus_di + minus_di)) * 100
adx = dx.rolling(period).mean()
return adx
def store(self, db_path: str, records: list[dict]) -> int:
"""Store OHLCV records into SQLite. Not used directly — see store_df."""
return 0
def store_df(self, df: pd.DataFrame, db_path: Optional[str] = None) -> int:
"""Store the DataFrame into the ohlcv_daily table."""
import sqlite3
db_path = db_path or config.db_path
conn = sqlite3.connect(db_path)
count = 0
for _, row in df.iterrows():
if pd.isna(row.get("date")):
continue
date_str = str(row["date"])
try:
conn.execute("""
INSERT OR REPLACE INTO ohlcv_daily
(date, symbol, open, high, low, close, volume,
ema20, ema60, ema120, atr_14, bb_width, adx_14)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
date_str, self.symbol,
float(row["open"]), float(row["high"]),
float(row["low"]), float(row["close"]),
float(row.get("volume", 0)),
float(row["ema20"]) if not pd.isna(row.get("ema20")) else None,
float(row["ema60"]) if not pd.isna(row.get("ema60")) else None,
float(row["ema120"]) if not pd.isna(row.get("ema120")) else None,
float(row["atr_14"]) if not pd.isna(row.get("atr_14")) else None,
float(row["bb_width"]) if not pd.isna(row.get("bb_width")) else None,
float(row["adx_14"]) if not pd.isna(row.get("adx_14")) else None,
))
count += 1
except Exception as e:
self.logger.debug(f"Skip row {date_str}: {e}")
conn.commit()
conn.close()
self.logger.info(f"Stored {count} OHLCV rows")
return count
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env python3
"""
main.py — ChanMacro entry point.
CLI: python main.py fetch|score|regime|serve
"""
import sys
import os
# Ensure package root is on path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cli import main
if __name__ == "__main__":
main()
-370
View File
@@ -1,370 +0,0 @@
"""
models.py — Pydantic v2 models and enums for ChanMacro.
All market state types, factor scores, and database record models.
"""
from datetime import date as Date
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_validator
# ═══════════════════════════════════════════════════════════════
# Shared validators
# ═══════════════════════════════════════════════════════════════
def _parse_date(v):
"""Reusable date-string parser for field_validator."""
if isinstance(v, str):
return Date.fromisoformat(v)
return v
# ═══════════════════════════════════════════════════════════════
# Enums
# ═══════════════════════════════════════════════════════════════
class MarketRegime(str, Enum):
"""V1: 3-state regime (factor-locked: Price + Breadth + Vol)."""
TREND = "TREND"
RANGE = "RANGE"
PANIC = "PANIC"
class OIState(str, Enum):
"""Discrete OI × Price state machine. NOT compressed into a score."""
NEW_LONGS = "New Longs"
SHORT_COVERING = "Short Covering"
NEW_SHORTS = "New Shorts"
LONG_EXIT = "Long Exit"
NEUTRAL = "Neutral"
class BreadthBucket(str, Enum):
"""Quantile-based breadth buckets — always have samples regardless of cycle."""
EXTREME = "EXTREME"
STRONG = "STRONG"
NORMAL = "NORMAL"
WEAK = "WEAK"
PANIC = "PANIC"
class VolRegime(str, Enum):
"""Volatility regime classification."""
LOW_VOL = "LOW_VOL"
NORMAL_VOL = "NORMAL_VOL"
HIGH_VOL = "HIGH_VOL"
EXPLOSIVE_VOL = "EXPLOSIVE_VOL"
class MacroDirection(str, Enum):
BULLISH = "bullish"
NEUTRAL = "neutral"
BEARISH = "bearish"
class MarketEmotion(str, Enum):
EXTREME_FEAR = "Extreme Fear"
FEAR = "Fear"
NEUTRAL = "Neutral"
GREED = "Greed"
EXTREME_GREED = "Extreme Greed"
class FlowState(str, Enum):
STRONG_INFLOW = "Strong Inflow"
INFLOW = "Inflow"
NEUTRAL = "Neutral"
OUTFLOW = "Outflow"
STRONG_OUTFLOW = "Strong Outflow"
class CapitalState(str, Enum):
ENTERING = "Entering"
STABLE = "Stable"
EXITING = "Exiting"
class SufficiencyLevel(str, Enum):
HIGH = "HIGH"
MEDIUM = "MEDIUM"
LOW = "LOW"
INSUFFICIENT = "INSUFFICIENT"
class SignalGrade(str, Enum):
A = "A"
B = "B"
C = "C"
# ═══════════════════════════════════════════════════════════════
# L0: Raw Data Models
# ═══════════════════════════════════════════════════════════════
class OHLCVDaily(BaseModel):
_parse_date = field_validator("date", mode="before")(_parse_date)
date: Date
symbol: str
open: float
high: float
low: float
close: float
volume: float
ema20: Optional[float] = None
ema60: Optional[float] = None
ema120: Optional[float] = None
atr_14: Optional[float] = None
bb_width: Optional[float] = None
adx_14: Optional[float] = None
class BreadthRecord(BaseModel):
_parse_date = field_validator("date", mode="before")(_parse_date)
date: Date
total_tracked: int = 50
advance_top50: int = 0
decline_top50: int = 0
above_ema20_top50: int = 0
new_highs_20d_top50: int = 0
btc_dominance: Optional[float] = None
advance_top20: int = 0
advance_top30: int = 0
above_ema20_top20: int = 0
above_ema20_top30: int = 0
new_highs_20d_top20: int = 0
new_highs_20d_top30: int = 0
class DerivativesRecord(BaseModel):
_parse_date = field_validator("date", mode="before")(_parse_date)
date: Date
symbol: str = "BTC/USDT:USDT"
funding_rate: Optional[float] = None
open_interest: Optional[float] = None
oi_24h_change_pct: Optional[float] = None
long_liquidations: Optional[float] = None
short_liquidations: Optional[float] = None
basis_annualised_pct: Optional[float] = None
source: str = "binance"
class ETFFlowRecord(BaseModel):
_parse_date = field_validator("date", mode="before")(_parse_date)
date: Date
product: str
net_flow_million: float
price: Optional[float] = None
source: str = "farside"
class StablecoinSupplyRecord(BaseModel):
_parse_date = field_validator("date", mode="before")(_parse_date)
date: Date
token: str
chain: str = "all"
supply: float
source: str = "defillama"
# ═══════════════════════════════════════════════════════════════
# L1: Factor Score Models
# ═══════════════════════════════════════════════════════════════
class FactorScore(BaseModel):
"""Single factor scoring output."""
name: str = ""
score: float = Field(default=50.0, ge=0.0, le=100.0)
label: str = ""
direction: MacroDirection = MacroDirection.NEUTRAL
sub_scores: dict = Field(default_factory=dict)
narrative: str = ""
class PriceStructureScore(FactorScore):
"""Price Structure — 3 sub-dimensions."""
trend_strength: float = 0.0
volatility_compression: float = 0.0
momentum: float = 0.0
class BreadthScore(FactorScore):
"""Breadth — multi-tier market diffusion."""
breadth_top20: float = 0.0
breadth_top30: float = 0.0
breadth_top50: float = 0.0
breadth_bucket: BreadthBucket = BreadthBucket.NORMAL
breadth_divergence: float = 0.0
advance_pct_top50: float = 0.0
above_ema20_pct_top50: float = 0.0
new_highs_top50: int = 0
btc_dominance_7d_chg: Optional[float] = None
class OIMatrixScore(FactorScore):
"""OI Matrix — discrete state + continuous score."""
oi_state: OIState = OIState.NEUTRAL
price_change_pct: float = 0.0
oi_change_pct: float = 0.0
class VolatilityRegimeScore(FactorScore):
"""Volatility regime classification."""
vol_regime: VolRegime = VolRegime.NORMAL_VOL
atr_pct: float = 0.0
hv_ratio: float = 1.0
bb_width_ratio: float = 1.0
# ═══════════════════════════════════════════════════════════════
# L4: Market State Vector (the final product)
# ═══════════════════════════════════════════════════════════════
class MarketStateVector(BaseModel):
"""L4: Complete market state description. NOT compressed into one number."""
_parse_date = field_validator("date", mode="before")(_parse_date)
date: Date
symbol: str = "BTC/USDT:USDT"
regime: MarketRegime
regime_confidence: float = Field(ge=0.0, le=1.0)
regime_version: str
regime_maturity_score: float = Field(ge=0.0, le=100.0, default=50.0)
breadth_top20: float = Field(default=50.0, ge=0.0, le=100.0)
breadth_top30: float = Field(default=50.0, ge=0.0, le=100.0)
breadth_top50: float = Field(default=50.0, ge=0.0, le=100.0)
breadth_bucket: BreadthBucket = BreadthBucket.NORMAL
breadth_divergence: float = 0.0
oi_state: OIState = OIState.NEUTRAL
volatility_regime: VolRegime = VolRegime.NORMAL_VOL
price_structure_score: FactorScore = Field(default_factory=FactorScore)
breadth_score: BreadthScore = Field(default_factory=BreadthScore)
oi_matrix_score: OIMatrixScore = Field(default_factory=OIMatrixScore)
volatility_regime_score: VolatilityRegimeScore = Field(default_factory=VolatilityRegimeScore)
market_state_hash: str = ""
def compute_hash(self) -> str:
import hashlib
key = f"{self.regime.value}|{self.breadth_bucket.value}|{self.oi_state.value}|{self.volatility_regime.value}"
return hashlib.md5(key.encode()).hexdigest()[:12]
def state_embedding(self) -> list[float]:
return [
self.breadth_top20,
self.breadth_top30,
self.breadth_top50,
self.regime_maturity_score,
self.price_structure_score.score,
]
# ═══════════════════════════════════════════════════════════════
# Factor Contribution
# ═══════════════════════════════════════════════════════════════
class FactorContribution(BaseModel):
"""How much a factor contributed to the overall score."""
factor: str
raw_score: float
weight: float
impact: float
direction: str # 'bullish' / 'bearish' / 'neutral'
# ═══════════════════════════════════════════════════════════════
# Regime Result
# ═══════════════════════════════════════════════════════════════
class RegimeResult(BaseModel):
_parse_date = field_validator("date", mode="before")(_parse_date)
date: Date
regime: MarketRegime
confidence: float
regime_version: str
maturity_score: float
all_scores: dict = Field(default_factory=dict)
prior_regime: Optional[MarketRegime] = None
confirmation_days: int = 0
class SignalFeatureRecord(BaseModel):
"""A single signal → market state → outcome record."""
_parse_date = field_validator("date", mode="before")(_parse_date)
date: Date
signal_type: str
signal_version: str = "b3_v1"
symbol: str = "BTC/USDT:USDT"
regime_version: str
signal_grade: Optional[SignalGrade] = None
signal_strength: Optional[float] = None
regime: MarketRegime
regime_confidence: float
regime_maturity_score: float
market_state_hash: str
state_embedding: str = "[]"
breadth_top20: float
breadth_top30: float
breadth_top50: float
breadth_bucket: BreadthBucket
breadth_divergence: float
oi_state: OIState
volatility_regime: VolRegime
price_structure_score: float
chan_trend_direction: Optional[str] = None
chan_pivot_count: Optional[int] = None
chan_divergence_type: Optional[str] = None
entry_price: Optional[float] = None
result_1d: Optional[float] = None
result_3d: Optional[float] = None
result_5d: Optional[float] = None
result_7d: Optional[float] = None
result_14d: Optional[float] = None
max_favorable_excursion: Optional[float] = None
max_adverse_excursion: Optional[float] = None
is_win_7d: Optional[int] = None
class ExpectancyLayer(BaseModel):
name: str
posterior_winrate: float
raw_winrate: Optional[float] = None
samples: int = 0
effective_samples: float = 0.0
avg_return: Optional[float] = None
class ExpectancyReport(BaseModel):
_parse_date = field_validator("date", mode="before")(_parse_date)
signal_type: str
date: Date
layers: list[ExpectancyLayer] = Field(default_factory=list)
final_estimate: float
sufficiency: SufficiencyLevel = SufficiencyLevel.INSUFFICIENT
prior_strength: int = 40
half_life_days: int = 180
source: str = "bayesian"
avg_return_7d: Optional[float] = None
profit_factor: Optional[float] = None
max_adverse_excursion: Optional[float] = None
class DailyOutput(BaseModel):
"""Final daily output: Market State + Expectancy."""
_parse_date = field_validator("date", mode="before")(_parse_date)
date: Date
market_state: MarketStateVector
expectancy: dict[str, ExpectancyReport] = Field(default_factory=dict)
ai_report_en: Optional[str] = None
ai_report_zh: Optional[str] = None
-213
View File
@@ -1,213 +0,0 @@
"""
regime_detector.py — Market regime detection (V1: 3 states).
★ FACTOR-LOCKED: Regime = f(Price Structure, Breadth, Volatility) — forever.
Fear, Liquidation, ETF, Funding are Context, NOT regime inputs.
Adding new factors MUST NOT change regime definition.
★ VERSIONED: regime_version = 'v1_price_breadth_vol'.
Weight changes → new version. Multiple versions coexist.
Query: WHERE regime_version = 'v1_price_breadth_vol'.
★ CONFIDENCE-BASED: Each regime gets a continuous score. Highest wins.
No hard thresholds (prevents boundary oscillation).
"""
from datetime import date as Date
from typing import Optional
from collections import deque
from models import MarketRegime, RegimeResult
from config import config
class RegimeDetector:
"""
Detects market regime from Price + Breadth + Vol.
V1: 3 regimes (TREND / RANGE / PANIC)
V2+: Can split TREND→TREND_UP/TREND_DOWN/EUPHORIA when samples > 500/regime.
"""
def __init__(self, regime_version: Optional[str] = None):
self.version = regime_version or config.regime_version
self.w_price = config.regime_w_price
self.w_breadth = config.regime_w_breadth
self.w_vol = config.regime_w_vol
self.panic_w_anti_trend = config.regime_panic_w_anti_trend
self.panic_w_vol_extreme = config.regime_panic_w_vol_extreme
# State persistence
self._current_regime: Optional[MarketRegime] = None
self._pending_regime: Optional[MarketRegime] = None
self._confirmation_count: int = 0
self._consecutive_days: int = 0
self._regime_history: deque = deque(maxlen=100)
# Confirmation: 2 days minimum
self.MIN_CONFIRMATION = 2
def load_state(self, db_path: str):
"""Restore regime state from the most recent regime_history record."""
import sqlite3
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT regime, confidence, confirmation_days, maturity_score "
"FROM regime_history ORDER BY date DESC LIMIT 1"
).fetchone()
conn.close()
if row:
regime_str = row["regime"]
if regime_str in ("TREND", "RANGE", "PANIC"):
self._current_regime = MarketRegime(regime_str)
self._consecutive_days = row["confirmation_days"] or 1
except Exception:
pass # DB not initialized yet, use defaults
def detect(self, price_structure_score: float, breadth_score: float,
volatility_regime: str, date: Date) -> RegimeResult:
"""
Detect regime from the 3 locked factors.
Args:
price_structure_score: 0-100 from PriceStructureScorer
breadth_score: 0-100 from BreadthScorer
volatility_regime: 'LOW_VOL'/'NORMAL_VOL'/'HIGH_VOL'/'EXPLOSIVE_VOL'
date: Target date
"""
# ── Compute regime scores ────────────────────────
# TREND: strong price + strong breadth + non-extreme vol
trend_score = (
price_structure_score * self.w_price +
breadth_score * self.w_breadth +
self._vol_to_trend(volatility_regime) * self.w_vol
)
# RANGE: neutral price + neutral breadth + low vol
# Score how "range-like" each dimension is
price_neutral = 60 - abs(price_structure_score - 50)
breadth_neutral = 60 - abs(breadth_score - 50)
vol_neutral = 80 if volatility_regime in ("LOW_VOL", "NORMAL_VOL") else 30
range_score = (
price_neutral * 0.40 +
breadth_neutral * 0.40 +
vol_neutral * 0.20
)
# PANIC: very weak trend + extreme vol (NO Fear/Liquidation!)
anti_trend = 100 - trend_score
vol_extreme = 100 if volatility_regime == "EXPLOSIVE_VOL" else (
60 if volatility_regime == "HIGH_VOL" else 20
)
panic_score = (
anti_trend * self.panic_w_anti_trend +
vol_extreme * self.panic_w_vol_extreme
)
scores = {
MarketRegime.TREND: round(trend_score, 1),
MarketRegime.RANGE: round(range_score, 1),
MarketRegime.PANIC: round(panic_score, 1),
}
best_regime = max(scores, key=scores.get)
# ── Persistence check ────────────────────────────
prior_regime = self._current_regime
if best_regime == self._current_regime:
self._consecutive_days += 1
self._pending_regime = None
self._confirmation_count = 0
elif best_regime == self._pending_regime:
self._confirmation_count += 1
if self._confirmation_count >= self.MIN_CONFIRMATION:
# Transition confirmed
prior_regime = self._current_regime
self._current_regime = best_regime
self._consecutive_days = self.MIN_CONFIRMATION
self._pending_regime = None
self._confirmation_count = 0
else:
self._pending_regime = best_regime
self._confirmation_count = 1
# Fallback: if no current regime yet (first run)
if self._current_regime is None:
self._current_regime = best_regime
self._consecutive_days = 1
# ── Confidence: for the CONFIRMED regime, not the raw best ──
confirmed_regime = self._current_regime
confidence = scores[confirmed_regime] / 100.0
# ── Maturity ─────────────────────────────────────
maturity = self._compute_maturity(
trend_score, breadth_score, volatility_regime
)
# Track history
self._regime_history.append({
"date": date,
"regime": confirmed_regime.value,
"confidence": round(confidence, 3),
})
return RegimeResult(
date=date,
regime=confirmed_regime,
confidence=round(confidence, 3),
prior_regime=prior_regime,
regime_version=self.version,
maturity_score=round(maturity, 1),
all_scores={k.value: v for k, v in scores.items()},
confirmation_days=self._consecutive_days,
)
@property
def current_regime(self) -> Optional[MarketRegime]:
return self._current_regime
@property
def pending_regime(self) -> Optional[MarketRegime]:
return self._pending_regime
@property
def confirmation_progress(self) -> tuple[int, int]:
"""(confirmed_days, required_days) for pending transition."""
return (self._confirmation_count, self.MIN_CONFIRMATION)
@staticmethod
def _vol_to_trend(vol_regime: str) -> float:
"""Convert volatility regime to trend-contributing score."""
mapping = {
"LOW_VOL": 50, # Low vol: neutral for trend
"NORMAL_VOL": 70, # Normal vol: good for trend
"HIGH_VOL": 60, # High vol: trending but risky
"EXPLOSIVE_VOL": 30, # Explosive: anti-trend
}
return mapping.get(vol_regime, 50)
@staticmethod
def _compute_maturity(trend_score: float, breadth_score: float,
vol_regime: str) -> float:
"""
Compute regime maturity: 0-100 continuous.
0-30: EMERGING (trend accelerating, breadth expanding)
30-70: CONFIRMED (stable)
70-100: EXHAUSTING (decelerating, vol abnormal)
"""
# Trend strength contribution
trend_contrib = trend_score * 0.50
# Breadth contribution
breadth_contrib = breadth_score * 0.30
# Vol contribution (inverted: low vol = early, explosive = late)
vol_contrib = {"LOW_VOL": 20, "NORMAL_VOL": 40, "HIGH_VOL": 60, "EXPLOSIVE_VOL": 85}
vol_val = vol_contrib.get(vol_regime, 50) * 0.20
return trend_contrib + breadth_contrib + vol_val
-8
View File
@@ -1,8 +0,0 @@
ccxt>=4.0.0
pandas>=2.0.0
numpy>=1.21.2
pydantic>=2.0.0
requests>=2.31.0
python-dotenv>=1.0.0
scipy>=1.10.0
flask>=3.0.0
-11
View File
@@ -1,11 +0,0 @@
#!/bin/bash
# run_tests.sh — Run the ChanMacro test suite.
#
# Usage:
# ./run_tests.sh # All tests
# ./run_tests.sh -v # Verbose
# ./run_tests.sh -k regime # Only regime tests
# ./run_tests.sh --cov # With coverage (requires pytest-cov)
cd "$(dirname "$0")"
python -m pytest tests/ "$@" --tb=short
-153
View File
@@ -1,153 +0,0 @@
"""
scheduler.py — 后台自动调度:定时拉取数据 + 计算因子 + 制度判定。
Python main.py cron → 前台阻塞运行,每 N 分钟一个 tick
Web app 启动时自动启动调度器 → 后台线程,不阻塞 Web 请求
"""
import threading
import logging
import time
from datetime import datetime, timezone, timedelta
from typing import Optional
logger = logging.getLogger("chanmacro.scheduler")
class MacroScheduler:
"""后台调度器:定时 fetch + score。"""
def __init__(self, interval_minutes: int = 60):
self.interval = interval_minutes
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._last_run: Optional[datetime] = None
self._running = False
def start(self) -> None:
"""启动后台线程。"""
if self._running:
return
self._stop.clear()
self._thread = threading.Thread(target=self._loop, name="macro-scheduler", daemon=True)
self._thread.start()
self._running = True
logger.info(f"调度器已启动, 每 {self.interval} 分钟执行一次")
def stop(self) -> None:
"""停止后台线程。"""
self._stop.set()
self._running = False
logger.info("调度器已停止")
@property
def last_run(self) -> Optional[datetime]:
return self._last_run
def _loop(self) -> None:
"""后台循环。"""
# 首次启动立即跑一次
self._tick()
while not self._stop.wait(self.interval * 60):
self._tick()
def _tick(self) -> None:
"""执行一次:fetch → score。"""
try:
from fetchers.ohlcv import OHLCVFetcher
from fetchers.breadth import BreadthFetcher
from fetchers.derivatives import DerivativesFetcher
from database import init_db
from datetime import date as Date
init_db()
today = Date.today()
# Fetch
ohlcv = OHLCVFetcher()
df = ohlcv.fetch()
if not df.empty:
ohlcv.store_df(df)
breadth = BreadthFetcher()
record = breadth.fetch()
if record:
breadth.store(record=record)
deriv = DerivativesFetcher()
records = deriv.fetch(today)
if records:
deriv.store(records=records)
# Score + Regime (also persisted inside _build_state)
from scoring.price_structure import PriceStructureScorer
from scoring.breadth_scorer import BreadthScorer
from scoring.oi_matrix import OIMatrixScorer
from scoring.volatility_regime import VolatilityRegimeScorer
from regime_detector import RegimeDetector
from models import MarketStateVector
from config import config
import json
from database import get_connection
ps = PriceStructureScorer().compute(today)
br = BreadthScorer().compute(today)
oi = OIMatrixScorer().compute(today)
vol = VolatilityRegimeScorer().compute(today)
detector = RegimeDetector()
detector.load_state(config.db_path)
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, today)
conn = get_connection()
conn.execute("""
INSERT OR REPLACE INTO regime_history
(date, regime, confidence, regime_version, maturity_score,
all_scores_json, prior_regime, confirmation_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
str(today), r.regime.value, r.confidence, r.regime_version,
r.maturity_score, json.dumps(r.all_scores),
r.prior_regime.value if r.prior_regime else None,
r.confirmation_days,
))
conn.commit()
conn.close()
# 检测新信号(每天运行一次,UTC 0 点后首次触发)
now = datetime.now(timezone.utc)
if self._last_run is None or now.date() > self._last_run.date():
try:
from chan_integration import ChanSignalDetector
detector = ChanSignalDetector()
# 检测最近 90 天的 4h 信号
count = detector.populate_signal_features(
start_date=(today - __import__('datetime').timedelta(days=90)).isoformat(),
end_date=today.isoformat(),
)
if count > 0:
logger.info(f"新增 {count} 条信号记录")
except Exception as e:
logger.debug(f"信号检测跳过: {e}")
self._last_run = now
logger.info(
f"Tick 完成: regime={r.regime.value} conf={r.confidence:.2f} "
f"breadth={br.score:.0f}({br.breadth_bucket.value}) "
f"price={ps.score:.0f} oi={oi.oi_state.value} vol={vol.vol_regime.value}"
)
except Exception as e:
logger.error(f"Tick 失败: {e}", exc_info=True)
# 单例
_scheduler: Optional[MacroScheduler] = None
def get_scheduler() -> MacroScheduler:
global _scheduler
if _scheduler is None:
_scheduler = MacroScheduler(interval_minutes=60)
return _scheduler
-6
View File
@@ -1,6 +0,0 @@
"""Scoring engine — L1 factor computation."""
from .base import BaseScorer
from .price_structure import PriceStructureScorer
from .breadth_scorer import BreadthScorer
from .oi_matrix import OIMatrixScorer
from .volatility_regime import VolatilityRegimeScorer
-28
View File
@@ -1,28 +0,0 @@
"""
scoring/base.py — Abstract base class for all scoring modules.
"""
from abc import ABC, abstractmethod
from datetime import date as Date
from typing import Optional
import sqlite3
from models import FactorScore
from config import config
class BaseScorer(ABC):
"""Abstract base for all factor scorers."""
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path or config.db_path
def get_connection(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
@abstractmethod
def compute(self, target_date: Date) -> FactorScore:
"""Compute factor score for a given date from database records."""
...
-218
View File
@@ -1,218 +0,0 @@
"""
scoring/breadth_scorer.py — Market Breadth Score.
The first citizen of the system. Diffusion always leads price.
Multi-tier: Top20 / Top30 / Top50.
Quantile-based bucketing: EXTREME / STRONG / NORMAL / WEAK / PANIC.
4 sub-indicators (equal weight):
1. Advance/Decline ratio (30%)
2. % above EMA20 (35%)
3. New 20d highs (20%)
4. BTC Dominance change (15%, inverted)
"""
from datetime import date as Date
import sqlite3
import numpy as np
import pandas as pd
from .base import BaseScorer
from .constants import (
BREADTH_W_ADVANCE, BREADTH_W_EMA20, BREADTH_W_NEW_HIGHS, BREADTH_W_BTC_DOM,
)
from models import FactorScore, BreadthScore, BreadthBucket, MacroDirection
from config import config
class BreadthScorer(BaseScorer):
"""Scores market breadth with quantile-based bucketing."""
def compute(self, target_date: Date) -> BreadthScore:
conn = self.get_connection()
try:
row = conn.execute(
"SELECT * FROM breadth_daily WHERE date = ?", (str(target_date),)
).fetchone()
if row is None:
return BreadthScore(
name="Breadth",
score=50.0,
label="No Data",
breadth_bucket=BreadthBucket.NORMAL,
)
row = dict(row)
total = row.get("total_tracked", 50) or 50
# 1. Advance/Decline ratio
advance = row.get("advance_top50", 0) or 0
decline = row.get("decline_top50", 0) or 0
if advance + decline > 0:
ad_ratio = advance / (advance + decline)
else:
ad_ratio = 0.5
ad_score = ad_ratio * 100
# 2. % above EMA20
above_ema = row.get("above_ema20_top50", 0) or 0
ema_pct = above_ema / total if total > 0 else 0.5
ema_score = ema_pct * 100
# 3. New highs
new_highs = row.get("new_highs_20d_top50", 0) or 0
highs_pct = new_highs / total if total > 0 else 0
highs_score = highs_pct * 100
# 4. BTC Dominance (inverted: BTC.D up = bearish for alts)
btc_dom = row.get("btc_dominance")
btc_dom_score = 50.0 # neutral default
if btc_dom is not None:
# Placeholder — needs historical comparison
btc_dom_score = 50.0
# Weighted aggregate
score = (
ad_score * BREADTH_W_ADVANCE +
ema_score * BREADTH_W_EMA20 +
highs_score * BREADTH_W_NEW_HIGHS +
btc_dom_score * BREADTH_W_BTC_DOM
)
# Multi-tier breadth
b20 = self._compute_tier_breadth(row, 20, total)
b30 = self._compute_tier_breadth(row, 30, total)
b50 = score # Top50 = full score
# Quantile bucket
bucket = self._assign_bucket(score)
# Divergence
divergence = b20 - b50
# Direction
if score >= 60:
direction = MacroDirection.BULLISH
elif score <= 40:
direction = MacroDirection.BEARISH
else:
direction = MacroDirection.NEUTRAL
# Narrative
narrative = self._build_narrative(bucket, divergence, ema_pct, ad_ratio)
return BreadthScore(
name="Breadth",
score=round(score, 1),
label=bucket.value,
direction=direction,
breadth_top20=round(b20, 1),
breadth_top30=round(b30, 1),
breadth_top50=round(b50, 1),
breadth_bucket=bucket,
breadth_divergence=round(divergence, 1),
advance_pct_top50=round(ad_ratio * 100, 1),
above_ema20_pct_top50=round(ema_pct * 100, 1),
new_highs_top50=new_highs,
sub_scores={
"advance_decline": round(ad_score, 1),
"above_ema20": round(ema_score, 1),
"new_highs": round(highs_score, 1),
"btc_dominance": round(btc_dom_score, 1),
},
narrative=narrative,
)
finally:
conn.close()
def _compute_tier_breadth(self, row: dict, tier: int, total: int) -> float:
"""Compute breadth score for a specific tier (Top20 or Top30)."""
advance = row.get(f"advance_top{tier}", 0) or 0
above_ema = row.get(f"above_ema20_top{tier}", 0) or 0
new_highs = row.get(f"new_highs_20d_top{tier}", 0) or 0
tier_actual = min(tier, total)
if tier_actual == 0:
return 50.0
ad_ratio = advance / tier_actual if tier_actual > 0 else 0.5
ema_ratio = above_ema / tier_actual if tier_actual > 0 else 0.5
highs_ratio = new_highs / tier_actual if tier_actual > 0 else 0
return (
ad_ratio * 100 * BREADTH_W_ADVANCE +
ema_ratio * 100 * BREADTH_W_EMA20 +
highs_ratio * 100 * BREADTH_W_NEW_HIGHS +
50 * BREADTH_W_BTC_DOM # neutral for BTC.D
)
def _assign_bucket(self, score: float) -> BreadthBucket:
"""Assign quantile-based bucket. V1 uses fixed thresholds until history accumulated."""
# V1: fixed thresholds (will switch to quantile when enough history)
if score >= 80:
return BreadthBucket.EXTREME
elif score >= 60:
return BreadthBucket.STRONG
elif score >= 40:
return BreadthBucket.NORMAL
elif score >= 20:
return BreadthBucket.WEAK
else:
return BreadthBucket.PANIC
@staticmethod
def compute_quantile_boundaries(db_path: str) -> dict:
"""Compute quantile boundaries from historical breadth data.
This should be called after accumulating enough history (> 1 year).
Returns boundaries for pd.qcut.
"""
conn = sqlite3.connect(db_path)
df = pd.read_sql_query(
"SELECT date, advance_top50, decline_top50, above_ema20_top50 FROM breadth_daily",
conn
)
conn.close()
if len(df) < 100:
return {"boundaries": [0, 20, 40, 60, 80, 100], "is_quantile": False}
df["ad_ratio"] = df["advance_top50"] / (df["advance_top50"] + df["decline_top50"])
df["ema_ratio"] = df["above_ema20_top50"] / 50
df["breadth_raw"] = (
df["ad_ratio"] * BREADTH_W_ADVANCE * 100 +
df["ema_ratio"] * BREADTH_W_EMA20 * 100 +
40 * BREADTH_W_NEW_HIGHS +
50 * BREADTH_W_BTC_DOM
)
boundaries = list(np.percentile(df["breadth_raw"].dropna(), [10, 30, 70, 90]))
return {
"boundaries": [0] + boundaries + [100],
"is_quantile": True,
"n_samples": len(df),
}
@staticmethod
def _build_narrative(bucket: BreadthBucket, divergence: float,
ema_pct: float, ad_ratio: float) -> str:
parts = []
if bucket == BreadthBucket.EXTREME:
parts.append(f"全市场极度扩散({ema_pct:.0%}站上EMA20)")
elif bucket == BreadthBucket.STRONG:
parts.append("市场广度强势")
elif bucket == BreadthBucket.NORMAL:
parts.append("市场广度中性")
elif bucket == BreadthBucket.WEAK:
parts.append("市场广度疲弱")
else:
parts.append("市场广度恐慌")
if divergence > 10:
parts.append("资金集中于大市值(Top20>>Top50)")
elif divergence < -10:
parts.append("垃圾币狂欢(Top50>>Top20)")
return ", ".join(parts)
-98
View File
@@ -1,98 +0,0 @@
"""
scoring/constants.py — Scoring thresholds, scale factors, and reference values.
All magic numbers in one place. Tune these via Phase 0 validation.
"""
# ── Price Structure ──────────────────────────────────────────
# ADX thresholds
ADX_TREND_THRESHOLD = 25 # ADX > 25 = trending
ADX_STRONG_THRESHOLD = 40 # ADX > 40 = strong trend
# EMA alignment
EMA_ALIGNMENT_BULLISH = 1.0 # EMA20 > EMA60 > EMA120
EMA_ALIGNMENT_NEUTRAL = 0.5 # mixed
EMA_ALIGNMENT_BEARISH = 0.0 # EMA20 < EMA60 < EMA120
# Volatility compression (BB width relative to 20d average)
BB_COMPRESSION_LOW = 0.7 # < 70% of avg = compressing
BB_COMPRESSION_HIGH = 1.5 # > 150% of avg = expanding
# Momentum (ROC annualized)
ROC_STRONG_BULLISH = 10.0 # % over period
ROC_STRONG_BEARISH = -10.0
# Consecutive candle threshold
CONSECUTIVE_CANDLES_SIGNAL = 4
# ── Breadth ──────────────────────────────────────────────────
# Quantile boundaries for breadth buckets
BREADTH_QUANTILES = [0, 0.1, 0.3, 0.7, 0.9, 1.0] # PANIC/WEAK/NORMAL/STRONG/EXTREME
# Breadth score computation weights
BREADTH_W_ADVANCE = 0.30 # advance/decline ratio
BREADTH_W_EMA20 = 0.35 # % above EMA20
BREADTH_W_NEW_HIGHS = 0.20 # new highs count
BREADTH_W_BTC_DOM = 0.15 # BTC dominance change (inverted)
# ── OI Matrix ────────────────────────────────────────────────
OI_PRICE_THRESHOLD = 0.5 # min |price_change%| to classify
OI_OI_THRESHOLD = 0.5 # min |OI_change%| to classify
# Score mapping for OI states
OI_STATE_SCORES = {
"New Longs": 85,
"Short Covering": 60,
"New Shorts": 20,
"Long Exit": 35,
"Neutral": 50,
}
# ── Volatility Regime ────────────────────────────────────────
VOL_LOW = 2.0 # ATR/Close % below this = LOW_VOL
VOL_HIGH = 5.0 # ATR/Close % below this = HIGH_VOL (above = EXPLOSIVE)
HV_RATIO_LOW = 0.7 # HV(20)/HV(60) below this = compressing
HV_RATIO_HIGH = 1.5 # HV(20)/HV(60) above this = expanding
# Score mapping
VOL_REGIME_SCORES = {
"LOW_VOL": 40, # Low vol → neutral with breakout potential
"NORMAL_VOL": 55,
"HIGH_VOL": 75,
"EXPLOSIVE_VOL": 90,
}
# ── Regime ───────────────────────────────────────────────────
REGIME_W_PRICE = 0.35
REGIME_W_BREADTH = 0.50
REGIME_W_VOL = 0.15
# PANIC: anti-trend + extreme vol (NO Fear/Liquidation)
PANIC_W_ANTI_TREND = 0.60
PANIC_W_VOL_EXTREME = 0.40
# ── Trend (L2) ───────────────────────────────────────────────
TREND_W_PRICE = 0.30
TREND_W_BREADTH = 0.70
# ── Maturity ─────────────────────────────────────────────────
MATURITY_W_TREND = 0.50
MATURITY_W_BREADTH = 0.30
MATURITY_W_VOL = 0.20
# ── Expectancy ───────────────────────────────────────────────
HALF_LIFE_DAYS = 180
SUFFICIENCY_MIN = 30
SUFFICIENCY_LOW = 50
SUFFICIENCY_MEDIUM = 100
LEVEL_MIN_SAMPLES = 50
KNN_MAX_DISTANCE = 0.35
KNN_K = 200
# ── Validation ───────────────────────────────────────────────
MIN_AVG_DURATION = 5
MAX_FLIP_RATE = 0.15
MIN_IC_THRESHOLD = 0.03
MIN_ICIR_THRESHOLD = 0.5
MIN_IG_THRESHOLD = 0.1 # Information Gain for regime factors
MIN_KL_THRESHOLD = 0.5 # KL Divergence for regime separation
-137
View File
@@ -1,137 +0,0 @@
"""
scoring/oi_matrix.py — OI × Price 2×2 state machine.
Discrete states, NOT a continuous score:
NEW_LONGS: Price↑ OI↑ → new money entering, trend continuation
SHORT_COVERING: Price↑ OI↓ → shorts covering, rally fragile
NEW_SHORTS: Price↓ OI↑ → new shorts entering, trend continuation
LONG_EXIT: Price↓ OI↓ → longs stopping out, panic (possible bottom)
NEUTRAL: flat → noise, don't force classification
"""
from datetime import date as Date
import sqlite3
from .base import BaseScorer
from .constants import OI_PRICE_THRESHOLD, OI_OI_THRESHOLD, OI_STATE_SCORES
from models import FactorScore, OIMatrixScore, OIState, MacroDirection
from config import config
class OIMatrixScorer(BaseScorer):
"""Classifies OI × Price state and assigns score."""
def compute(self, target_date: Date) -> OIMatrixScore:
conn = self.get_connection()
try:
row = conn.execute(
"SELECT * FROM derivatives WHERE date = ? AND symbol = 'BTC/USDT:USDT'",
(str(target_date),)
).fetchone()
if row is None:
return OIMatrixScore(
name="OI Matrix",
score=50.0,
label="No Data",
oi_state=OIState.NEUTRAL,
)
row = dict(row)
oi_change = row.get("oi_24h_change_pct") or 0
# Get price change from OHLCV
price_change = self._get_price_change(conn, str(target_date))
# Classify state
oi_state = self._classify(price_change, oi_change)
# Score from state
score = OI_STATE_SCORES.get(oi_state.value, 50)
# Direction
if oi_state == OIState.NEW_LONGS:
direction = MacroDirection.BULLISH
elif oi_state == OIState.SHORT_COVERING:
direction = MacroDirection.BULLISH # bullish but fragile
elif oi_state == OIState.NEW_SHORTS:
direction = MacroDirection.BEARISH
elif oi_state == OIState.LONG_EXIT:
direction = MacroDirection.BEARISH # bearish but possible bottom
else:
direction = MacroDirection.NEUTRAL
# Narrative
narrative = self._build_narrative(oi_state, price_change, oi_change)
return OIMatrixScore(
name="OI Matrix",
score=float(score),
label=oi_state.value,
direction=direction,
oi_state=oi_state,
price_change_pct=round(price_change, 2),
oi_change_pct=round(oi_change, 2),
sub_scores={
"price_change_pct": round(price_change, 2),
"oi_change_pct": round(oi_change, 2),
},
narrative=narrative,
)
finally:
conn.close()
def _get_price_change(self, conn: sqlite3.Connection, date_str: str) -> float:
"""Get BTC 24h price change % for a given date."""
row = conn.execute(
"SELECT close FROM ohlcv_daily WHERE date = ? AND symbol = 'BTC/USDT:USDT'",
(date_str,)
).fetchone()
if row is None:
return 0.0
# Get previous day close
prev = conn.execute(
"SELECT close FROM ohlcv_daily WHERE date < ? AND symbol = 'BTC/USDT:USDT' ORDER BY date DESC LIMIT 1",
(date_str,)
).fetchone()
if prev is None:
return 0.0
current_close = float(row["close"])
prev_close = float(prev["close"])
if prev_close == 0:
return 0.0
return (current_close - prev_close) / prev_close * 100
@staticmethod
def _classify(price_change_pct: float, oi_change_pct: float) -> OIState:
"""Classify OI × Price into discrete state."""
price_up = price_change_pct > OI_PRICE_THRESHOLD
price_down = price_change_pct < -OI_PRICE_THRESHOLD
oi_up = oi_change_pct > OI_OI_THRESHOLD
oi_down = oi_change_pct < -OI_OI_THRESHOLD
if price_up and oi_up:
return OIState.NEW_LONGS
elif price_up and oi_down:
return OIState.SHORT_COVERING
elif price_down and oi_up:
return OIState.NEW_SHORTS
elif price_down and oi_down:
return OIState.LONG_EXIT
else:
return OIState.NEUTRAL
@staticmethod
def _build_narrative(state: OIState, price_chg: float, oi_chg: float) -> str:
mapping = {
OIState.NEW_LONGS: f"新多进场: 价格+{price_chg:.1f}%, OI+{oi_chg:.1f}%, 真金白银推动",
OIState.SHORT_COVERING: f"空头回补: 价格+{price_chg:.1f}%, OI{oi_chg:.1f}%, 上涨脆弱",
OIState.NEW_SHORTS: f"新空进场: 价格{price_chg:.1f}%, OI+{oi_chg:.1f}%, 趋势延续",
OIState.LONG_EXIT: f"多头止损: 价格{price_chg:.1f}%, OI{oi_chg:.1f}%, 恐慌(可能见底)",
OIState.NEUTRAL: "OI/价格变化不显著, 噪音区",
}
return mapping.get(state, "Unknown")
-248
View File
@@ -1,248 +0,0 @@
"""
scoring/price_structure.py — Price Structure Score (OHLCV-only).
Three sub-dimensions:
1. Trend Strength (40%): EMA alignment + ADX
2. Volatility Compression (30%): ATR + BB width
3. Momentum (30%): ROC + consecutive candles
This module works with zero external dependencies — just OHLCV data.
"""
from datetime import date as Date
import sqlite3
import math
import numpy as np
import pandas as pd
from .base import BaseScorer
from .constants import (
ADX_TREND_THRESHOLD, ADX_STRONG_THRESHOLD,
BB_COMPRESSION_LOW, BB_COMPRESSION_HIGH,
ROC_STRONG_BULLISH, ROC_STRONG_BEARISH,
CONSECUTIVE_CANDLES_SIGNAL,
)
from models import FactorScore, PriceStructureScore, MacroDirection
from config import config
class PriceStructureScorer(BaseScorer):
"""Scores market structure from OHLCV data alone."""
def compute(self, target_date: Date) -> PriceStructureScore:
conn = self.get_connection()
try:
df = self._load_ohlcv(conn, str(target_date), lookback=120)
if df.empty:
return PriceStructureScore(
name="Price Structure",
score=50.0,
label="No Data",
)
trend = self._score_trend_strength(df)
vol_comp = self._score_volatility_compression(df)
momentum = self._score_momentum(df)
# Weighted aggregate
score = trend * 0.40 + vol_comp * 0.30 + momentum * 0.30
# Determine direction
if trend > 60:
direction = MacroDirection.BULLISH
elif trend < 40:
direction = MacroDirection.BEARISH
else:
direction = MacroDirection.NEUTRAL
# Build narrative
latest = df.iloc[-1]
narrative = self._build_narrative(trend, vol_comp, momentum, latest)
return PriceStructureScore(
name="Price Structure",
score=round(score, 1),
label=self._label(score),
direction=direction,
trend_strength=round(trend, 1),
volatility_compression=round(vol_comp, 1),
momentum=round(momentum, 1),
sub_scores={
"trend_strength": round(trend, 1),
"volatility_compression": round(vol_comp, 1),
"momentum": round(momentum, 1),
},
narrative=narrative,
)
finally:
conn.close()
def _load_ohlcv(self, conn: sqlite3.Connection, date_str: str,
lookback: int = 120) -> pd.DataFrame:
"""Load OHLCV data up to target_date."""
df = pd.read_sql_query(
"SELECT * FROM ohlcv_daily WHERE date <= ? ORDER BY date DESC LIMIT ?",
conn, params=(date_str, lookback)
)
if df.empty:
return df
return df.sort_values("date").reset_index(drop=True)
def _score_trend_strength(self, df: pd.DataFrame) -> float:
"""Score trend based on EMA alignment and ADX."""
latest = df.iloc[-1]
# EMA alignment
ema20 = latest.get("ema20")
ema60 = latest.get("ema60")
ema120 = latest.get("ema120")
ema_score = 50.0
if ema20 and ema60 and ema120 and not pd.isna(ema20) and not pd.isna(ema60) and not pd.isna(ema120):
alignments = 0
if ema20 > ema60: alignments += 1
if ema60 > ema120: alignments += 1
if ema20 > ema120: alignments += 1
# Distance from EMAs
close = float(latest["close"])
ema20_dist = abs(close - ema20) / ema20 * 100 if ema20 else 0
if alignments == 3:
ema_score = 80 + min(ema20_dist, 15) # strong bullish alignment
elif alignments == 0:
ema_score = 20 - min(ema20_dist, 15) # strong bearish alignment
elif alignments == 2:
ema_score = 65
else:
ema_score = 35
# ADX
adx = latest.get("adx_14")
adx_score = 50.0
if adx and not pd.isna(adx):
if adx > ADX_STRONG_THRESHOLD:
adx_score = 85
elif adx > ADX_TREND_THRESHOLD:
adx_score = 65 + (adx - ADX_TREND_THRESHOLD) / (ADX_STRONG_THRESHOLD - ADX_TREND_THRESHOLD) * 20
else:
adx_score = 50 - (ADX_TREND_THRESHOLD - adx) / ADX_TREND_THRESHOLD * 30
return ema_score * 0.55 + adx_score * 0.45
def _score_volatility_compression(self, df: pd.DataFrame) -> float:
"""Score volatility compression — expansion = high, compression = low-mid."""
latest = df.iloc[-1]
bb_width = latest.get("bb_width")
if not bb_width or pd.isna(bb_width) or len(df) < 20:
return 50.0
# BB width relative to 20d average
recent_bb = df["bb_width"].dropna().tail(20)
if len(recent_bb) < 10:
return 50.0
bb_avg = recent_bb.mean()
bb_ratio = bb_width / bb_avg if bb_avg > 0 else 1.0
if bb_ratio < BB_COMPRESSION_LOW:
# Compression → potential breakout, neutral-bullish
return 45 + (BB_COMPRESSION_LOW - bb_ratio) * 30
elif bb_ratio > BB_COMPRESSION_HIGH:
# Expansion → trending or chaotic
return 75 + min((bb_ratio - BB_COMPRESSION_HIGH) * 20, 20)
else:
# Normal
return 55
def _score_momentum(self, df: pd.DataFrame) -> float:
"""Score momentum using ROC and consecutive candles."""
if len(df) < 10:
return 50.0
closes = df["close"].astype(float)
latest = float(closes.iloc[-1])
# ROC (5-bar)
if len(closes) >= 6:
roc5 = (closes.iloc[-1] - closes.iloc[-6]) / closes.iloc[-6] * 100
else:
roc5 = 0
# ROC (10-bar)
if len(closes) >= 11:
roc10 = (closes.iloc[-1] - closes.iloc[-11]) / closes.iloc[-11] * 100
else:
roc10 = 0
# ROC (20-bar)
if len(closes) >= 21:
roc20 = (closes.iloc[-1] - closes.iloc[-21]) / closes.iloc[-21] * 100
else:
roc20 = 0
# Score ROC: map to 0-100
def roc_to_score(roc, scale=15):
return 50 + np.clip(roc / scale * 50, -50, 50)
roc_score = roc_to_score(roc5, 10) * 0.4 + roc_to_score(roc10, 15) * 0.35 + roc_to_score(roc20, 20) * 0.25
# Consecutive candle direction
consec_score = 50.0
consec_up = 0
consec_down = 0
for i in range(len(closes) - 1, max(0, len(closes) - 10), -1):
if closes.iloc[i] > closes.iloc[i - 1]:
consec_up += 1
consec_down = 0
elif closes.iloc[i] < closes.iloc[i - 1]:
consec_down += 1
consec_up = 0
else:
break
if consec_up >= CONSECUTIVE_CANDLES_SIGNAL:
consec_score = 70 + min(consec_up * 5, 25)
elif consec_down >= CONSECUTIVE_CANDLES_SIGNAL:
consec_score = 30 - min(consec_down * 5, 25)
return roc_score * 0.70 + consec_score * 0.30
def _build_narrative(self, trend: float, vol: float, momentum: float,
latest: pd.Series) -> str:
parts = []
if trend > 65:
parts.append("EMA多头排列+ADX趋势明确")
elif trend > 50:
parts.append("趋势温和偏多")
elif trend < 35:
parts.append("EMA空头排列+ADX趋势明确")
elif trend < 50:
parts.append("趋势温和偏空")
else:
parts.append("趋势中性")
if vol > 70:
parts.append("波动率扩张")
elif vol < 45:
parts.append("波动率压缩(突破前兆)")
if momentum > 65:
parts.append("动量强劲")
elif momentum < 35:
parts.append("动量疲弱")
return ", ".join(parts) if parts else "中性"
@staticmethod
def _label(score: float) -> str:
if score >= 75:
return "Strong Bullish Structure"
elif score >= 60:
return "Bullish Structure"
elif score >= 40:
return "Neutral Structure"
elif score >= 25:
return "Bearish Structure"
return "Weak Bearish Structure"
-143
View File
@@ -1,143 +0,0 @@
"""
scoring/volatility_regime.py — Volatility Regime Classification.
4 regimes from OHLCV data:
LOW_VOL: ATR/Close < 2% → compression, breakout imminent
NORMAL_VOL: ATR/Close 2-5% → normal trading
HIGH_VOL: ATR/Close 5-10% → trend acceleration, wider stops
EXPLOSIVE_VOL: ATR/Close > 10% → extreme, reduce or wait
Uses: ATR(14)/Close, HV(20)/HV(60) ratio, BB width ratio.
OHLCV-only — never goes offline.
"""
from datetime import date as Date
import sqlite3
import numpy as np
import pandas as pd
from .base import BaseScorer
from .constants import (
VOL_LOW, VOL_HIGH, VOL_REGIME_SCORES, HV_RATIO_LOW, HV_RATIO_HIGH,
)
from models import FactorScore, VolatilityRegimeScore, VolRegime, MacroDirection
from config import config
class VolatilityRegimeScorer(BaseScorer):
"""Classifies volatility regime from OHLCV data."""
def compute(self, target_date: Date) -> VolatilityRegimeScore:
conn = self.get_connection()
try:
df = pd.read_sql_query(
"SELECT * FROM ohlcv_daily WHERE date <= ? ORDER BY date DESC LIMIT 120",
conn, params=(str(target_date),)
)
if df.empty:
return VolatilityRegimeScore(
name="Volatility Regime",
score=50.0,
label="No Data",
)
df = df.sort_values("date").reset_index(drop=True)
# 1. ATR/Close %
latest = df.iloc[-1]
atr = latest.get("atr_14")
close = float(latest["close"])
atr_pct = (atr / close * 100) if atr and not pd.isna(atr) and close > 0 else 3.0
# 2. HV(20) / HV(60) ratio
hv_ratio = self._compute_hv_ratio(df)
# 3. BB width ratio
bb_ratio = self._compute_bb_ratio(df)
# Classify regime
regime = self._classify(atr_pct, hv_ratio, bb_ratio)
# Score
score = VOL_REGIME_SCORES.get(regime.value, 50)
# Narrative
narrative = self._build_narrative(regime, atr_pct, hv_ratio, bb_ratio)
return VolatilityRegimeScore(
name="Volatility Regime",
score=float(score),
label=regime.value,
direction=MacroDirection.NEUTRAL,
vol_regime=regime,
atr_pct=round(atr_pct, 2),
hv_ratio=round(hv_ratio, 2),
bb_width_ratio=round(bb_ratio, 2),
sub_scores={
"atr_pct": round(atr_pct, 2),
"hv_ratio": round(hv_ratio, 2),
"bb_width_ratio": round(bb_ratio, 2),
},
narrative=narrative,
)
finally:
conn.close()
def _compute_hv_ratio(self, df: pd.DataFrame) -> float:
"""Compute HV(20) / HV(60) ratio."""
closes = df["close"].astype(float)
returns = closes.pct_change().dropna()
if len(returns) < 60:
return 1.0
hv20 = returns.tail(20).std() * np.sqrt(365) * 100
hv60 = returns.tail(60).std() * np.sqrt(365) * 100
if hv60 == 0:
return 1.0
return hv20 / hv60
def _compute_bb_ratio(self, df: pd.DataFrame) -> float:
"""Compute current BB width / 20d average BB width."""
bb_widths = df["bb_width"].dropna().tail(40)
if len(bb_widths) < 20:
return 1.0
current = bb_widths.iloc[-1]
avg = bb_widths.tail(20).mean()
if avg == 0:
return 1.0
return current / avg
@staticmethod
def _classify(atr_pct: float, hv_ratio: float, bb_ratio: float) -> VolRegime:
"""Classify volatility regime from multiple indicators."""
# Primary: ATR/Close %
if atr_pct > 10.0:
return VolRegime.EXPLOSIVE_VOL
elif atr_pct > VOL_HIGH:
return VolRegime.HIGH_VOL
elif atr_pct < VOL_LOW:
return VolRegime.LOW_VOL
# Secondary: HV ratio and BB ratio for edge cases
if hv_ratio > HV_RATIO_HIGH and bb_ratio > 1.3:
return VolRegime.HIGH_VOL
elif hv_ratio < HV_RATIO_LOW and bb_ratio < 0.8:
return VolRegime.LOW_VOL
return VolRegime.NORMAL_VOL
@staticmethod
def _build_narrative(regime: VolRegime, atr_pct: float,
hv_ratio: float, bb_ratio: float) -> str:
mapping = {
VolRegime.LOW_VOL: f"低波动(ATR={atr_pct:.1f}%), 布林带收窄, 突破前兆",
VolRegime.NORMAL_VOL: f"正常波动(ATR={atr_pct:.1f}%), 正常交易环境",
VolRegime.HIGH_VOL: f"高波动(ATR={atr_pct:.1f}%), 趋势加速, 放宽止损",
VolRegime.EXPLOSIVE_VOL: f"极端波动(ATR={atr_pct:.1f}%), 减仓或等待",
}
return mapping.get(regime, "Unknown")
-134
View File
@@ -1,134 +0,0 @@
"""
tests/conftest.py — Shared fixtures for ChanMacro tests.
"""
import os
import sys
import pytest
import sqlite3
import numpy as np
import pandas as pd
from datetime import date, timedelta
from pathlib import Path
# Ensure package root on path
sys.path.insert(0, str(Path(__file__).parent.parent))
@pytest.fixture
def db_path(tmp_path):
"""Create a temporary SQLite database with full mock data."""
db = str(tmp_path / "test_macro.db")
from database import init_db
conn = init_db(db)
np.random.seed(42)
base = date(2025, 9, 1)
n_days = 300
# Generate realistic price series with 3 regime periods
prices = [90000]
regimes = []
for i in range(n_days):
if i < 100:
ret = np.random.normal(0.003, 0.015)
regime = "TREND"
elif i < 200:
ret = np.random.normal(0.000, 0.012)
regime = "RANGE"
else:
ret = np.random.normal(-0.003, 0.025)
regime = "PANIC"
prices.append(prices[-1] * (1 + ret))
regimes.append(regime)
for i in range(n_days):
d = base + timedelta(days=i)
c = prices[i]
r = regimes[i]
# OHLCV
conn.execute("""
INSERT OR REPLACE INTO ohlcv_daily
(date,symbol,open,high,low,close,volume,ema20,ema60,ema120,atr_14,bb_width,adx_14)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
d.strftime("%Y-%m-%d"), "BTC/USDT:USDT",
c * 0.99, c * 1.03, c * 0.97, c, 1000,
c * (0.98 if r == "TREND" else 1.02 if r == "PANIC" else 1.0),
c * (0.95 if r == "TREND" else 1.05 if r == "PANIC" else 1.0),
c * (0.90 if r == "TREND" else 1.10 if r == "PANIC" else 1.0),
c * (0.02 if r == "PANIC" else 0.015),
4.5, 28.0 if r == "TREND" else 18.0,
))
# Breadth
adv = 42 if r == "TREND" else 25 if r == "RANGE" else 8
conn.execute("""
INSERT OR REPLACE INTO breadth_daily
(date,total_tracked,advance_top50,decline_top50,above_ema20_top50,
new_highs_20d_top50,advance_top30,advance_top20,
above_ema20_top30,above_ema20_top20,new_highs_20d_top30,new_highs_20d_top20)
VALUES (?,50,?,?,?,?,?,?,?,?,?,?)
""", (
d.strftime("%Y-%m-%d"), adv, 50 - adv, adv, min(adv, 15),
int(adv * 0.7), int(adv * 0.5), int(adv * 0.7), int(adv * 0.5),
min(int(adv * 0.7), 12), min(int(adv * 0.5), 8),
))
# Derivatives
oi_chg = 3.5 if r == "TREND" else 0.5 if r == "RANGE" else -2.0
conn.execute("""
INSERT OR REPLACE INTO derivatives
(date,symbol,funding_rate,open_interest,oi_24h_change_pct,
long_liquidations,short_liquidations,basis_annualised_pct)
VALUES (?,?,?,?,?,?,?,?)
""", (
d.strftime("%Y-%m-%d"), "BTC/USDT:USDT",
0.0001 + np.random.normal(0, 0.0002),
35e9, oi_chg + np.random.normal(0, 1.0),
50e6 * np.random.random(), 30e6 * np.random.random(),
8.5 if r == "TREND" else 3.0,
))
# Regime history
conn.execute("""
INSERT OR REPLACE INTO regime_history
(date,regime,confidence,regime_version,maturity_score,all_scores_json,confirmation_days)
VALUES (?,?,?,?,?,?,?)
""", (d.strftime("%Y-%m-%d"), r, 0.75, "v1_price_breadth_vol", 50, "{}", 1))
conn.commit()
conn.close()
# Override config to use test DB
from config import config
old_db = config.db_path
config.db_path = db
yield db
config.db_path = old_db
@pytest.fixture
def sample_state(db_path):
"""Build a MarketStateVector for a known test date."""
from models import (
MarketStateVector, MarketRegime, BreadthBucket,
OIState, VolRegime,
)
state = MarketStateVector(
date=date(2026, 3, 15),
regime=MarketRegime.TREND,
regime_confidence=0.82,
regime_version="v1_price_breadth_vol",
regime_maturity_score=55.0,
breadth_top20=82.0,
breadth_top30=78.0,
breadth_top50=74.0,
breadth_bucket=BreadthBucket.STRONG,
breadth_divergence=8.0,
oi_state=OIState.NEW_LONGS,
volatility_regime=VolRegime.NORMAL_VOL,
)
state.market_state_hash = state.compute_hash()
return state
-173
View File
@@ -1,173 +0,0 @@
"""Test SignalTracker, TimeDecay, and BayesianExpectancyEngine."""
import pytest
from datetime import date, timedelta
import numpy as np
class TestTimeDecay:
def test_recent_weight_near_one(self):
from expectancy.decay import TimeDecay
d = TimeDecay(180)
w = d.weight(date(2026, 6, 20), date(2026, 6, 24))
assert 0.95 < w < 1.0
def test_old_weight_decays(self):
from expectancy.decay import TimeDecay
d = TimeDecay(180)
w = d.weight(date(2025, 6, 24), date(2026, 6, 24))
assert 0.2 < w < 0.3 # ~365 days at half_life=180
def test_effective_samples(self):
from expectancy.decay import TimeDecay
d = TimeDecay(180)
dates = [date(2026, 6, 24)] * 10
weights = d.weights(dates, date(2026, 6, 24))
eff = d.effective_samples(weights)
assert eff == pytest.approx(10.0, rel=0.01)
def test_weighted_win_rate(self):
from expectancy.decay import TimeDecay
d = TimeDecay(180)
wins = np.array([1, 0, 1, 0])
weights = np.array([1.0, 1.0, 1.0, 1.0])
wr = d.weighted_win_rate(wins, weights)
assert wr == 0.5
def test_weight_at_age(self):
from expectancy.decay import TimeDecay
w = TimeDecay.weight_at_age(180, 180)
assert w == pytest.approx(0.5, rel=0.01)
class TestSignalTracker:
def test_record_signal(self, db_path, sample_state):
from expectancy.tracker import SignalTracker
tracker = SignalTracker()
rid = tracker.record(
date(2026, 3, 15), "B3", 98000.0, sample_state,
signal_grade="A", signal_strength=75.0,
)
assert rid is not None
assert rid > 0
def test_get_samples(self, db_path, sample_state):
from expectancy.tracker import SignalTracker
tracker = SignalTracker()
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
tracker.record(date(2026, 3, 16), "B2", 98500.0, sample_state)
samples = tracker.get_samples(signal_type="B3")
assert len(samples) == 1
assert samples[0]["signal_type"] == "B3"
def test_count_samples(self, db_path, sample_state):
from expectancy.tracker import SignalTracker
tracker = SignalTracker()
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
tracker.record(date(2026, 3, 16), "B3", 98500.0, sample_state)
counts = tracker.count_samples()
assert "B3/TREND" in counts
assert counts["B3/TREND"] == 2
def test_filter_by_regime(self, db_path, sample_state):
from expectancy.tracker import SignalTracker
tracker = SignalTracker()
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
samples = tracker.get_samples(signal_type="B3", regime="TREND")
assert len(samples) == 1
samples = tracker.get_samples(signal_type="B3", regime="PANIC")
assert len(samples) == 0
def test_backfill_signals(self, db_path, sample_state):
from expectancy.tracker import SignalTracker
tracker = SignalTracker()
signals = [
{"date": date(2026, 3, 15), "signal_type": "B3", "entry_price": 98000},
{"date": date(2026, 3, 20), "signal_type": "B2", "entry_price": 99000},
]
count = tracker.backfill_signals(signals)
assert count == 2
class TestBayesianExpectancyEngine:
def test_estimate_returns_report(self, db_path, sample_state):
from expectancy.tracker import SignalTracker
from expectancy.engine import BayesianExpectancyEngine
# Record some signals first
tracker = SignalTracker()
for i in range(10):
tracker.record(
date(2026, 3, 15) + timedelta(days=i),
"B3", 98000.0, sample_state,
)
engine = BayesianExpectancyEngine(level_min_samples=3)
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
assert report.signal_type == "B3"
assert len(report.layers) > 0
assert report.source in ("bayesian", "insufficient")
def test_insufficient_with_no_samples(self, db_path, sample_state):
from expectancy.engine import BayesianExpectancyEngine
engine = BayesianExpectancyEngine(level_min_samples=10)
report = engine.estimate(sample_state, "B1", date(2026, 3, 25))
assert report.sufficiency.value in ("INSUFFICIENT", "LOW", "MEDIUM", "HIGH")
def test_empirical_bayes_shrinks_small_samples(self, db_path, sample_state):
"""With N=3, raw=100%, posterior should be pulled toward prior."""
from expectancy.tracker import SignalTracker
from expectancy.engine import BayesianExpectancyEngine
tracker = SignalTracker()
for i in range(3):
tracker.record(
date(2026, 3, 15) + timedelta(days=i),
"B3", 98000.0, sample_state,
)
engine = BayesianExpectancyEngine(level_min_samples=1)
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
# With small N, posterior should differ from raw
base_layer = report.layers[0]
if base_layer.raw_winrate and base_layer.samples < 50:
# Posterior should be pulled toward prior (50% or global rate)
if base_layer.raw_winrate > 0.8:
assert base_layer.posterior_winrate < base_layer.raw_winrate
def test_leveled_fallback_stops_at_min_samples(self, db_path, sample_state):
from expectancy.tracker import SignalTracker
from expectancy.engine import BayesianExpectancyEngine
tracker = SignalTracker()
for i in range(20):
tracker.record(date(2026, 3, 15) + timedelta(days=i), "B3", 98000.0, sample_state)
engine = BayesianExpectancyEngine(level_min_samples=15)
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
# Should have stopped at a level with >= 15 effective samples
assert report.final_estimate >= 0
class TestSufficiencyGuard:
def test_insufficient(self):
from expectancy.engine import SufficiencyGuard
from models import SufficiencyLevel
g = SufficiencyGuard()
assert g.evaluate(10) == SufficiencyLevel.INSUFFICIENT
def test_low(self):
from expectancy.engine import SufficiencyGuard
from models import SufficiencyLevel
g = SufficiencyGuard()
assert g.evaluate(40) == SufficiencyLevel.LOW
def test_high(self):
from expectancy.engine import SufficiencyGuard
from models import SufficiencyLevel
g = SufficiencyGuard()
assert g.evaluate(200) == SufficiencyLevel.HIGH
-130
View File
@@ -1,130 +0,0 @@
"""Test all Pydantic models and enums."""
import pytest
from datetime import date
from models import (
MarketRegime, OIState, BreadthBucket, VolRegime,
MarketStateVector, FactorScore, RegimeResult,
SignalFeatureRecord, ExpectancyReport, DailyOutput,
FactorContribution, SufficiencyLevel, SignalGrade,
)
class TestEnums:
def test_regime_values(self):
assert MarketRegime.TREND.value == "TREND"
assert MarketRegime.RANGE.value == "RANGE"
assert MarketRegime.PANIC.value == "PANIC"
def test_oi_state_has_neutral(self):
assert OIState.NEUTRAL.value == "Neutral"
assert len(OIState) == 5
def test_breadth_bucket_values(self):
assert BreadthBucket.EXTREME.value == "EXTREME"
assert len(BreadthBucket) == 5
def test_vol_regime_values(self):
assert VolRegime.LOW_VOL.value == "LOW_VOL"
assert VolRegime.EXPLOSIVE_VOL.value == "EXPLOSIVE_VOL"
class TestMarketStateVector:
def test_minimal_construction(self):
sv = MarketStateVector(
date="2026-06-24",
regime=MarketRegime.TREND,
regime_confidence=0.82,
regime_version="v1_price_breadth_vol",
)
assert sv.date == date(2026, 6, 24)
assert sv.regime == MarketRegime.TREND
assert sv.breadth_top50 == 50.0 # default
def test_date_string_parsing(self):
sv = MarketStateVector(
date="2026-01-15",
regime=MarketRegime.RANGE,
regime_confidence=0.55,
regime_version="v1_price_breadth_vol",
)
assert sv.date == date(2026, 1, 15)
def test_compute_hash(self):
sv = MarketStateVector(
date="2026-06-24",
regime=MarketRegime.TREND,
regime_confidence=0.82,
regime_version="v1_price_breadth_vol",
breadth_bucket=BreadthBucket.EXTREME,
oi_state=OIState.NEW_LONGS,
volatility_regime=VolRegime.NORMAL_VOL,
)
h = sv.compute_hash()
assert len(h) == 12
# Same state = same hash
sv2 = MarketStateVector(
date="2026-06-25",
regime=MarketRegime.TREND,
regime_confidence=0.80,
regime_version="v1_price_breadth_vol",
breadth_bucket=BreadthBucket.EXTREME,
oi_state=OIState.NEW_LONGS,
volatility_regime=VolRegime.NORMAL_VOL,
)
assert sv2.compute_hash() == h
def test_state_embedding(self):
sv = MarketStateVector(
date="2026-06-24",
regime=MarketRegime.TREND,
regime_confidence=0.82,
regime_version="v1_price_breadth_vol",
breadth_top20=80.0,
breadth_top30=75.0,
breadth_top50=70.0,
regime_maturity_score=60.0,
)
emb = sv.state_embedding()
assert len(emb) == 5
assert emb[0] == 80.0
assert emb[3] == 60.0
class TestRegimeResult:
def test_construction(self):
r = RegimeResult(
date="2026-06-24",
regime=MarketRegime.TREND,
confidence=0.82,
regime_version="v1_price_breadth_vol",
maturity_score=55.0,
all_scores={"TREND": 82.0, "RANGE": 45.0, "PANIC": 20.0},
confirmation_days=5,
)
assert r.regime == MarketRegime.TREND
assert r.confirmation_days == 5
class TestExpectancyReport:
def test_insufficient(self):
r = ExpectancyReport(
signal_type="B3",
date="2026-06-24",
final_estimate=0.0,
sufficiency=SufficiencyLevel.INSUFFICIENT,
source="insufficient",
)
assert r.final_estimate == 0.0
assert r.sufficiency == SufficiencyLevel.INSUFFICIENT
class TestFactorContribution:
def test_construction(self):
fc = FactorContribution(
factor="ETF Flow",
raw_score=85.0,
weight=0.1925,
impact=6.7,
direction="bullish",
)
assert fc.impact > 0
-109
View File
@@ -1,109 +0,0 @@
"""Test regime detector and validation."""
import pytest
from datetime import date
import pandas as pd
import numpy as np
class TestRegimeDetector:
def test_detects_trend(self):
from regime_detector import RegimeDetector
from models import MarketRegime
d = RegimeDetector()
r = d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
assert r.regime == MarketRegime.TREND
assert r.confidence > 0.5
def test_detects_range(self):
from regime_detector import RegimeDetector
from models import MarketRegime
d = RegimeDetector()
r = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 24))
assert r.regime in (MarketRegime.RANGE, MarketRegime.TREND)
def test_detects_panic(self):
from regime_detector import RegimeDetector
from models import MarketRegime
d = RegimeDetector()
r = d.detect(15.0, 10.0, "EXPLOSIVE_VOL", date(2026, 6, 24))
assert r.regime == MarketRegime.PANIC
def test_2day_confirmation(self):
from regime_detector import RegimeDetector
from models import MarketRegime
d = RegimeDetector()
# Day 1: RANGE
r1 = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 24))
assert r1.regime == MarketRegime.RANGE # first run, no confirmation needed
# Day 2: still RANGE
r2 = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 25))
assert r2.regime == MarketRegime.RANGE
assert r2.confirmation_days == 2
def test_transition_needs_confirmation(self):
from regime_detector import RegimeDetector
from models import MarketRegime
d = RegimeDetector()
# Establish TREND
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 25))
# Day 3: weak scores → raw best = RANGE, but TREND should persist
r3 = d.detect(35.0, 40.0, "NORMAL_VOL", date(2026, 6, 26))
# First day of pending transition — should still be TREND
assert r3.regime == MarketRegime.TREND
assert d.pending_regime is not None
def test_version_is_stored(self):
from regime_detector import RegimeDetector
d = RegimeDetector(regime_version="v1_price_breadth_vol")
r = d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
assert r.regime_version == "v1_price_breadth_vol"
def test_load_state(self, db_path):
from regime_detector import RegimeDetector
from models import MarketRegime
d = RegimeDetector()
d.load_state(db_path)
# DB has TREND for first 100 days, so most recent should load
assert d.current_regime is not None
def test_confidence_for_confirmed_regime(self):
"""Confidence should be for the confirmed regime, not raw best."""
from regime_detector import RegimeDetector
from models import MarketRegime
d = RegimeDetector()
# Establish TREND
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 25))
# Now feed weak scores → raw best would be PANIC or RANGE
r = d.detect(15.0, 10.0, "EXPLOSIVE_VOL", date(2026, 6, 26))
# Should still report TREND (need 2 confirmations to switch)
assert r.regime == MarketRegime.TREND
class TestTransitionValidator:
def test_stable_regime_passes(self):
from validation.transition_validator import TransitionValidator
# Create stable regime sequence: long periods
seq = pd.Series(
["TREND"] * 50 + ["RANGE"] * 50 + ["PANIC"] * 40,
index=pd.date_range("2026-01-01", periods=140),
)
tv = TransitionValidator()
report = tv.validate(seq)
assert report.is_stable
assert report.avg_duration > 20
assert report.flip_rate < 0.05
def test_unstable_regime_fails(self):
from validation.transition_validator import TransitionValidator
# Create unstable sequence: flips every 2 days
seq = pd.Series(
["TREND", "TREND", "RANGE", "RANGE", "TREND", "TREND",
"PANIC", "PANIC", "RANGE", "RANGE"] * 5,
index=pd.date_range("2026-01-01", periods=50),
)
tv = TransitionValidator()
report = tv.validate(seq)
assert not report.is_stable
assert report.flip_rate > 0.15
-121
View File
@@ -1,121 +0,0 @@
"""Test all 4 core scorers."""
import pytest
from datetime import date
class TestPriceStructureScorer:
def test_computes_score(self, db_path):
from scoring.price_structure import PriceStructureScorer
scorer = PriceStructureScorer()
result = scorer.compute(date(2026, 3, 15))
assert result.name == "Price Structure"
assert 0 <= result.score <= 100
assert result.trend_strength >= 0
assert result.volatility_compression >= 0
assert result.momentum >= 0
assert result.label
def test_bullish_in_trend(self, db_path):
from scoring.price_structure import PriceStructureScorer
scorer = PriceStructureScorer()
result = scorer.compute(date(2025, 11, 15)) # TREND period
assert result.score > 50 # Should be bullish in uptrend
def test_bearish_in_panic(self, db_path):
from scoring.price_structure import PriceStructureScorer
scorer = PriceStructureScorer()
result = scorer.compute(date(2026, 5, 15)) # PANIC period
# In panic period, EMA alignment should be bearish
assert result.trend_strength < 60
def test_no_data_handling(self, db_path):
from scoring.price_structure import PriceStructureScorer
scorer = PriceStructureScorer()
result = scorer.compute(date(2020, 1, 1))
assert result.score == 50.0
assert result.label == "No Data"
class TestBreadthScorer:
def test_computes_score(self, db_path):
from scoring.breadth_scorer import BreadthScorer
scorer = BreadthScorer()
result = scorer.compute(date(2026, 3, 15))
assert result.name == "Breadth"
assert 0 <= result.score <= 100
assert result.breadth_bucket
assert result.breadth_top20 >= 0
assert result.breadth_top50 >= 0
def test_tier_values(self, db_path):
from scoring.breadth_scorer import BreadthScorer
scorer = BreadthScorer()
result = scorer.compute(date(2025, 11, 15)) # TREND period
# Top20 should generally be higher than Top50 (large caps lead)
assert result.breadth_top20 >= 0
assert result.breadth_top50 >= 0
def test_bucket_assignment(self, db_path):
from scoring.breadth_scorer import BreadthScorer, BreadthBucket
scorer = BreadthScorer()
result = scorer.compute(date(2025, 11, 15)) # TREND: adv=42/50
assert result.breadth_bucket in (
BreadthBucket.EXTREME, BreadthBucket.STRONG, BreadthBucket.NORMAL
)
def test_no_data(self, db_path):
from scoring.breadth_scorer import BreadthScorer
scorer = BreadthScorer()
result = scorer.compute(date(2020, 1, 1))
assert result.score == 50.0
class TestOIMatrixScorer:
def test_computes_state(self, db_path):
from scoring.oi_matrix import OIMatrixScorer, OIState
scorer = OIMatrixScorer()
result = scorer.compute(date(2025, 11, 15)) # TREND period, oi_chg=+3.5
assert result.oi_state in OIState
assert 0 <= result.score <= 100
def test_new_longs_in_trend(self, db_path):
from scoring.oi_matrix import OIMatrixScorer, OIState
scorer = OIMatrixScorer()
# Test multiple dates in TREND period — at least one should be NEW_LONGS or NEUTRAL
found_bullish = False
for d in ["2025-11-15", "2025-11-20", "2025-12-01", "2025-12-15"]:
result = scorer.compute(date.fromisoformat(d))
if result.oi_state in (OIState.NEW_LONGS, OIState.SHORT_COVERING, OIState.NEUTRAL):
found_bullish = True
break
assert found_bullish, "No bullish OI state found in TREND period"
def test_no_data(self, db_path):
from scoring.oi_matrix import OIMatrixScorer
scorer = OIMatrixScorer()
result = scorer.compute(date(2020, 1, 1))
assert result.score == 50.0
assert result.label == "No Data"
class TestVolatilityRegimeScorer:
def test_computes_regime(self, db_path):
from scoring.volatility_regime import VolatilityRegimeScorer, VolRegime
scorer = VolatilityRegimeScorer()
result = scorer.compute(date(2026, 3, 15))
assert result.vol_regime in VolRegime
assert 0 <= result.score <= 100
def test_higher_vol_in_panic(self, db_path):
from scoring.volatility_regime import VolatilityRegimeScorer, VolRegime
scorer = VolatilityRegimeScorer()
trend_result = scorer.compute(date(2025, 11, 15))
panic_result = scorer.compute(date(2026, 5, 15))
# PANIC period has higher ATR → higher vol regime or score
assert panic_result.atr_pct >= trend_result.atr_pct * 0.5 # at least comparable
def test_no_data(self, db_path):
from scoring.volatility_regime import VolatilityRegimeScorer
scorer = VolatilityRegimeScorer()
result = scorer.compute(date(2020, 1, 1))
assert result.score == 50.0
-81
View File
@@ -1,81 +0,0 @@
"""
trend_detector.py Trend strength and maturity helpers.
Utility functions for computing trend alignment, acceleration, persistence.
Used by regime_detector and price_structure scorer.
"""
import numpy as np
import pandas as pd
def ema_alignment_score(close: float, ema20: float, ema60: float, ema120: float) -> float:
"""Score EMA alignment: 0=bearish, 50=neutral, 100=bullish."""
if any(pd.isna(x) for x in [ema20, ema60, ema120]):
return 50.0
alignments = 0
if ema20 > ema60:
alignments += 1
if ema60 > ema120:
alignments += 1
if ema20 > ema120:
alignments += 1
if alignments == 3:
return 85.0
elif alignments == 2:
return 65.0
elif alignments == 1:
return 35.0
else:
return 15.0
def adx_trend_score(adx: float) -> float:
"""Convert ADX value to trend score: 0-100."""
if pd.isna(adx):
return 50.0
if adx > 40:
return 90.0
elif adx > 25:
return 60.0 + (adx - 25) / 15 * 30
elif adx > 15:
return 40.0 + (adx - 15) / 10 * 20
else:
return max(10.0, adx / 15 * 40)
def breadth_persistence(breadth_scores: list[float], window: int = 5) -> float:
"""How consistently has breadth stayed at its current level? 0-100."""
if len(breadth_scores) < window:
return 50.0
recent = breadth_scores[-window:]
mean_val = np.mean(recent)
std_val = np.std(recent) if len(recent) > 1 else 0
# Low std = high persistence
persistence = 100 - min(std_val * 5, 100)
# Bias: higher breadth = higher persistence score
return persistence * 0.5 + mean_val * 0.5
def trend_strength_composite(ema_score: float, adx_score: float,
breadth_score: float) -> float:
"""Composite trend strength 0-100."""
return ema_score * 0.25 + adx_score * 0.25 + breadth_score * 0.50
def compute_maturity(trend_strength: float, breadth_persistence: float,
vol_expansion: float) -> float:
"""
Compute regime maturity score 0-100.
EMERGING (0-30): trend accelerating, breadth expanding
CONFIRMED (30-70): trend stable, breadth stable
EXHAUSTING (70-100): trend decelerating, breadth contracting, vol abnormal
"""
return (
trend_strength * 0.50 +
breadth_persistence * 0.30 +
(100 - vol_expansion) * 0.20 # inverted: low vol = early stage
)
-5
View File
@@ -1,5 +0,0 @@
"""Validation Framework — Phase 0: verify every factor before trusting it."""
from .factor_validator import FactorValidator
from .regime_validator import RegimeValidator
from .transition_validator import TransitionValidator
from .reporter import ValidationReporter
-174
View File
@@ -1,174 +0,0 @@
"""
validation/factor_validator.py Validates a factor's predictive power.
Tests: IC, ICIR, Hit Ratio, Quantile Spread, Lead-Lag analysis.
Answers: "Does this factor predict future returns?"
"""
from datetime import date as Date
from typing import Optional
import sqlite3
import logging
import numpy as np
import pandas as pd
from config import config
from .metrics import (
information_coefficient, icir, hit_ratio,
quantile_spread, lead_lag_ic,
)
logger = logging.getLogger(__name__)
class FactorReport:
"""Structured report for a single factor's validation results."""
def __init__(self, factor_name: str):
self.factor_name = factor_name
self.ic_mean: float = 0.0
self.ic_std: float = 0.0
self.icir: float = 0.0
self.hit_ratio: float = 0.0
self.quantile_spread: float = 0.0
self.is_leading: bool = False
self.lead_days: int = 0
self.lead_ic: float = 0.0
self.n_observations: int = 0
self.conclusion: str = ""
def summary(self) -> str:
lines = [
f"Factor: {self.factor_name}",
f" N={self.n_observations}",
f" IC mean={self.ic_mean:.4f} std={self.ic_std:.4f} ICIR={self.icir:.2f}",
f" Hit Ratio={self.hit_ratio:.1%} Top-Bot Spread={self.quantile_spread:.4f}",
f" Best Lead: {self.lead_days}d (IC={self.lead_ic:.4f})" if self.is_leading else " Leading: No (synchronous/lagging)",
f"{self.conclusion}",
]
return "\n".join(lines)
class FactorValidator:
"""
Validates a factor's predictive power using standard quant metrics.
For each forward horizon (1d, 3d, 5d, 7d, 14d), computes:
- IC (Spearman rank correlation)
- ICIR (IC stability)
- Hit Ratio (direction accuracy)
- Quantile spread (top vs bottom bucket)
- Lead-lag profile
A factor is valid if IC > 0.03 and ICIR > 0.5.
For regime factors, also check regime_validator.
"""
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path or config.db_path
def validate(self, factor_name: str, factor_scores: pd.Series,
forward_returns: dict[str, pd.Series]) -> FactorReport:
"""
Args:
factor_name: Human-readable name
factor_scores: Series indexed by date, values 0-100
forward_returns: Dict of horizon Series indexed by date (e.g. "1d" returns)
"""
report = FactorReport(factor_name)
# Align series to common dates
common_idx = factor_scores.index
for ret in forward_returns.values():
common_idx = common_idx.intersection(ret.index)
if len(common_idx) < 30:
report.conclusion = "INSUFFICIENT DATA (< 30 observations)"
return report
f = factor_scores[common_idx]
report.n_observations = len(common_idx)
# Test against 7d forward returns (primary horizon)
primary_ret = forward_returns.get("7d")
if primary_ret is None:
# Use first available
primary_ret = list(forward_returns.values())[0]
r = primary_ret[common_idx]
# IC
ic = information_coefficient(f, r)
report.ic_mean = round(ic, 4)
# Rolling IC for ICIR
rolling_ics = []
for i in range(30, len(f)):
ic_i = information_coefficient(f.iloc[:i], r.iloc[:i])
rolling_ics.append(ic_i)
ic_series = pd.Series(rolling_ics)
report.ic_std = round(ic_series.std(), 4)
report.icir = round(icir(ic_series), 2)
# Hit ratio
report.hit_ratio = round(hit_ratio(f, r), 4)
# Quantile spread
report.quantile_spread = round(quantile_spread(f, r), 4)
# Lead-lag
lead = lead_lag_ic(f, r, max_lag=14)
report.is_leading = lead["is_leading"]
report.lead_days = lead["lead_days"]
report.lead_ic = round(lead["best_ic"], 4)
# Conclusion
if abs(report.ic_mean) > 0.05 and report.icir > 1.0:
report.conclusion = "STRONG: significant predictive power"
elif abs(report.ic_mean) > 0.03 and report.icir > 0.5:
report.conclusion = "VALID: moderate predictive power"
elif abs(report.ic_mean) < 0.02:
report.conclusion = "CONFIRMING: describes current state, not predictive"
else:
report.conclusion = "WEAK: borderline, monitor or downweight"
return report
def validate_from_db(self, factor_name: str,
score_query: str,
horizon_days: int = 7) -> FactorReport:
"""
Convenience: load scores from DB and OHLCV returns, then validate.
score_query: SQL that returns (date, score) pairs.
"""
conn = sqlite3.connect(self.db_path)
scores_df = pd.read_sql_query(score_query, conn)
if scores_df.empty:
conn.close()
r = FactorReport(factor_name)
r.conclusion = "NO DATA"
return r
scores_df["date"] = pd.to_datetime(scores_df["date"])
scores = scores_df.set_index("date")["score"]
# Load forward returns from OHLCV
ohlcv = pd.read_sql_query(
"SELECT date, close FROM ohlcv_daily WHERE symbol='BTC/USDT:USDT' ORDER BY date",
conn
)
conn.close()
ohlcv["date"] = pd.to_datetime(ohlcv["date"])
ohlcv = ohlcv.set_index("date")
ohlcv["ret"] = ohlcv["close"].pct_change().shift(-1) # forward 1d
# Build forward returns for multiple horizons
forward = {}
for h in [1, 3, 5, 7, 14]:
forward[str(h) + "d"] = ohlcv["close"].pct_change(periods=h).shift(-h)
return self.validate(factor_name, scores, forward)
-192
View File
@@ -1,192 +0,0 @@
"""
validation/metrics.py Shared statistical metrics for factor and regime validation.
"""
import numpy as np
import pandas as pd
from scipy import stats
from typing import Optional
def information_coefficient(factor: pd.Series, forward_returns: pd.Series) -> float:
"""Spearman rank IC between factor values and forward returns."""
mask = factor.notna() & forward_returns.notna()
if mask.sum() < 10:
return 0.0
ic, _ = stats.spearmanr(factor[mask], forward_returns[mask])
return float(ic) if not np.isnan(ic) else 0.0
def icir(ic_series: pd.Series) -> float:
"""Information Coefficient IR = mean(IC) / std(IC)."""
if len(ic_series) < 5 or ic_series.std() == 0:
return 0.0
return float(ic_series.mean() / ic_series.std())
def hit_ratio(factor: pd.Series, forward_returns: pd.Series) -> float:
"""Fraction of times factor direction matches return direction."""
mask = factor.notna() & forward_returns.notna()
if mask.sum() < 10:
return 0.5
# Compare sign of factor deviation from median vs sign of returns
factor_median = factor[mask].median()
factor_sign = np.sign(factor[mask] - factor_median)
return_sign = np.sign(forward_returns[mask])
return float((factor_sign == return_sign).mean())
def quantile_spread(factor: pd.Series, forward_returns: pd.Series,
n_quantiles: int = 5) -> float:
"""Top vs bottom quantile return spread (分层回测)."""
mask = factor.notna() & forward_returns.notna()
if mask.sum() < n_quantiles * 3:
return 0.0
f = factor[mask]
r = forward_returns[mask]
labels = pd.qcut(f, n_quantiles, labels=False, duplicates="drop")
top_ret = r[labels == labels.max()].mean()
bot_ret = r[labels == labels.min()].mean()
return float(top_ret - bot_ret)
def lead_lag_ic(factor: pd.Series, returns: pd.Series,
max_lag: int = 14) -> dict:
"""Find the best leading/trailing relationship by computing IC at each lag."""
results = {}
for lag in range(-max_lag, max_lag + 1):
if lag < 0:
shifted = factor.shift(abs(lag))
ic = information_coefficient(shifted, returns)
results[f"lead_{abs(lag)}d"] = ic
elif lag > 0:
shifted = returns.shift(lag)
ic = information_coefficient(factor, shifted)
results[f"lag_{lag}d"] = ic
else:
ic = information_coefficient(factor, returns)
results["sync"] = ic
# Find best lead period
lead_ics = {k: v for k, v in results.items() if k.startswith("lead_")}
best_lead = max(lead_ics, key=lead_ics.get) if lead_ics else "sync"
best_ic = lead_ics.get(best_lead, results.get("sync", 0))
return {
"best_lead": best_lead,
"best_ic": best_ic,
"ic_curve": results,
"is_leading": best_lead.startswith("lead_") and abs(best_ic) > 0.03,
"lead_days": int(best_lead.split("_")[1].rstrip("d")) if best_lead.startswith("lead_") else 0,
}
def mutual_information(factor: pd.Series, labels: pd.Series,
n_bins: int = 10) -> float:
"""Mutual information between factor (binned) and discrete regime labels."""
mask = factor.notna() & labels.notna()
if mask.sum() < 20:
return 0.0
f = factor[mask]
l = labels[mask]
try:
f_binned = pd.qcut(f, n_bins, labels=False, duplicates="drop")
except ValueError:
f_binned = pd.cut(f, n_bins, labels=False)
mi = 0.0
for fi in range(n_bins):
p_f = (f_binned == fi).mean()
if p_f == 0:
continue
for li in l.unique():
p_l = (l == li).mean()
p_joint = ((f_binned == fi) & (l == li)).mean()
if p_joint > 0:
mi += p_joint * np.log(p_joint / (p_f * p_l))
return float(mi)
def kl_divergence(factor: pd.Series, labels: pd.Series,
regime_a: str, regime_b: str, n_bins: int = 10) -> float:
"""KL divergence between factor distributions in two regimes."""
mask_a = (labels == regime_a) & factor.notna()
mask_b = (labels == regime_b) & factor.notna()
if mask_a.sum() < 10 or mask_b.sum() < 10:
return 0.0
try:
hist_a, edges = np.histogram(factor[mask_a], bins=n_bins, density=True)
hist_b, _ = np.histogram(factor[mask_b], bins=edges, density=True)
except ValueError:
return 0.0
hist_a = np.clip(hist_a, 1e-10, None)
hist_b = np.clip(hist_b, 1e-10, None)
return float((hist_a * np.log(hist_a / hist_b)).sum())
def anova_f_score(factor: pd.Series, labels: pd.Series) -> float:
"""ANOVA F-statistic: how well factor separates different regimes."""
mask = factor.notna() & labels.notna()
if mask.sum() < 20:
return 0.0
groups = [factor[mask][labels[mask] == lbl] for lbl in labels[mask].unique()]
groups = [g for g in groups if len(g) > 1]
if len(groups) < 2:
return 0.0
f_stat, _ = stats.f_oneway(*groups)
return float(f_stat) if not np.isnan(f_stat) else 0.0
def transition_matrix(labels: pd.Series) -> pd.DataFrame:
"""Compute Markov transition matrix from regime sequence."""
unique = sorted(labels.dropna().unique())
n = len(unique)
matrix = np.zeros((n, n))
seq = labels.dropna().values
for i in range(len(seq) - 1):
from_idx = unique.index(seq[i])
to_idx = unique.index(seq[i + 1])
matrix[from_idx][to_idx] += 1
# Row-normalize
row_sums = matrix.sum(axis=1, keepdims=True)
row_sums[row_sums == 0] = 1
matrix = matrix / row_sums
return pd.DataFrame(matrix, index=unique, columns=unique)
def regime_duration_stats(labels: pd.Series) -> dict:
"""Compute average duration, flip rate, state entropy for regime sequence."""
seq = labels.dropna().values
if len(seq) < 2:
return {"avg_duration": 0, "flip_rate": 0, "state_entropy": 0, "n_days": len(seq)}
# Count durations
durations = []
current = seq[0]
count = 1
flips = 0
for i in range(1, len(seq)):
if seq[i] == current:
count += 1
else:
durations.append(count)
current = seq[i]
count = 1
flips += 1
durations.append(count)
avg_dur = float(np.mean(durations)) if durations else 0
flip_rate = flips / len(seq)
# State entropy
_, counts = np.unique(seq, return_counts=True)
probs = counts / counts.sum()
entropy = float(-(probs * np.log2(probs + 1e-10)).sum())
return {
"avg_duration": round(avg_dur, 1),
"flip_rate": round(flip_rate, 3),
"state_entropy": round(entropy, 3),
"n_days": len(seq),
}
-144
View File
@@ -1,144 +0,0 @@
"""
validation/regime_validator.py Validates factors as regime separators.
Tests: Mutual Information, KL Divergence, ANOVA F-score.
Answers: "Does this factor distinguish different market regimes?"
Key insight: a factor may have low IC (poor return predictor) but high
regime separation (good regime classifier). Breadth is the prime example.
"""
from datetime import date as Date
from typing import Optional
import sqlite3
import logging
import numpy as np
import pandas as pd
from config import config
from .metrics import (
mutual_information, kl_divergence, anova_f_score,
)
logger = logging.getLogger(__name__)
class RegimeReport:
"""Structured report for regime separation validation."""
def __init__(self, factor_name: str):
self.factor_name = factor_name
self.mutual_info: float = 0.0
self.anova_f: float = 0.0
self.kl_pairs: dict = {} # (regime_a, regime_b) → KL divergence
self.best_separates: list[str] = []
self.separation_score: float = 0.0
self.is_regime_factor: bool = False
self.conclusion: str = ""
def summary(self) -> str:
lines = [
f"Factor: {self.factor_name}",
f" Mutual Information: {self.mutual_info:.4f}",
f" ANOVA F: {self.anova_f:.1f}",
f" Best separates: {', '.join(self.best_separates) if self.best_separates else 'none'}",
f" Regime Factor: {'YES' if self.is_regime_factor else 'No'}",
f"{self.conclusion}",
]
return "\n".join(lines)
class RegimeValidator:
"""
Validates a factor's ability to separate different market regimes.
A good regime factor has:
- Mutual Information > 0.1
- KL Divergence between regimes > 0.5
- ANOVA F-score high
"""
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path or config.db_path
def validate(self, factor_name: str, factor_scores: pd.Series,
regime_labels: pd.Series) -> RegimeReport:
"""
Args:
factor_name: Human-readable name
factor_scores: Series indexed by date, values 0-100
regime_labels: Series indexed by date, values = 'TREND'/'RANGE'/'PANIC'
"""
report = RegimeReport(factor_name)
# Align
common_idx = factor_scores.index.intersection(regime_labels.index)
if len(common_idx) < 30:
report.conclusion = "INSUFFICIENT DATA"
return report
f = factor_scores[common_idx]
labels = regime_labels[common_idx]
# Mutual Information
report.mutual_info = round(mutual_information(f, labels), 4)
# ANOVA
report.anova_f = round(anova_f_score(f, labels), 1)
# KL Divergence between each pair of regimes
unique_regimes = sorted(labels.unique())
for i, ra in enumerate(unique_regimes):
for rb in unique_regimes[i + 1:]:
kl = kl_divergence(f, labels, ra, rb)
report.kl_pairs[f"{ra}{rb}"] = round(kl, 4)
# Best separation
if report.kl_pairs:
sorted_pairs = sorted(report.kl_pairs, key=report.kl_pairs.get, reverse=True)
report.best_separates = sorted_pairs[:2]
# Separation score (0-1 composite)
mi_norm = min(report.mutual_info / 0.5, 1.0)
kl_avg = np.mean(list(report.kl_pairs.values())) if report.kl_pairs else 0
kl_norm = min(kl_avg / 1.0, 1.0)
report.separation_score = round(0.5 * mi_norm + 0.5 * kl_norm, 2)
# Is this a good regime factor?
report.is_regime_factor = (
report.mutual_info > 0.1 and
kl_avg > 0.5
)
if report.separation_score > 0.8:
report.conclusion = "EXCELLENT regime separator"
elif report.separation_score > 0.5:
report.conclusion = "GOOD regime separator"
elif report.separation_score > 0.3:
report.conclusion = "MODERATE — some regime separation"
else:
report.conclusion = "WEAK regime separator"
return report
def validate_from_db(self, factor_name: str,
score_query: str) -> RegimeReport:
"""Load scores and regime labels from DB, then validate."""
conn = sqlite3.connect(self.db_path)
scores_df = pd.read_sql_query(score_query, conn)
regimes_df = pd.read_sql_query(
"SELECT date, regime FROM regime_history", conn
)
conn.close()
if scores_df.empty or regimes_df.empty:
r = RegimeReport(factor_name)
r.conclusion = "NO DATA"
return r
scores = scores_df.set_index("date")["score"]
regimes = regimes_df.set_index("date")["regime"]
return self.validate(factor_name, scores, regimes)
-120
View File
@@ -1,120 +0,0 @@
"""
validation/reporter.py Aggregates all validation reports into a unified summary.
Used by: python main.py validate
"""
from datetime import date as Date
from typing import Optional
import logging
from .factor_validator import FactorValidator, FactorReport
from .regime_validator import RegimeValidator, RegimeReport
from .transition_validator import TransitionValidator, TransitionReport
logger = logging.getLogger(__name__)
class ValidationReporter:
"""
Orchestrates full validation pipeline:
1. Factor validation (IC, ICIR, Hit Ratio) for each factor
2. Regime validation (MI, KL, ANOVA) for each factor
3. Transition validation (stability, flip rate)
"""
def __init__(self, db_path: Optional[str] = None):
from config import config
self.db_path = db_path or config.db_path
self.factor_validator = FactorValidator(self.db_path)
self.regime_validator = RegimeValidator(self.db_path)
self.transition_validator = TransitionValidator(self.db_path)
def run_all(self) -> str:
"""Run all validations and return a formatted report string."""
lines = []
lines.append("=" * 70)
lines.append(f" ChanMacro Validation Report — {Date.today()}")
lines.append("=" * 70)
# ── Factor Validation ──────────────────────────
lines.append("")
lines.append("" * 50)
lines.append(" FACTOR VALIDATION (Predictive Power)")
lines.append("" * 50)
factor_queries = {
"Price Structure": "SELECT date, score FROM ohlcv_daily WHERE ema20 IS NOT NULL",
"Breadth": """
SELECT bd.date,
(bd.advance_top50*1.0/(bd.advance_top50+bd.decline_top50+1)*100*0.30
+ bd.above_ema20_top50*1.0/50*100*0.35
+ bd.new_highs_20d_top50*1.0/50*100*0.20
+ 50*0.15) as score
FROM breadth_daily bd
""",
}
factor_reports: list[FactorReport] = []
for name, query in factor_queries.items():
try:
report = self.factor_validator.validate_from_db(name, query)
factor_reports.append(report)
lines.append(report.summary())
lines.append("")
except Exception as e:
logger.warning(f"Factor validation failed for {name}: {e}")
# ── Regime Validation ──────────────────────────
lines.append("" * 50)
lines.append(" REGIME VALIDATION (Regime Separation)")
lines.append("" * 50)
regime_reports: list[RegimeReport] = []
for name, query in factor_queries.items():
try:
report = self.regime_validator.validate_from_db(name, query)
regime_reports.append(report)
lines.append(report.summary())
lines.append("")
except Exception as e:
logger.warning(f"Regime validation failed for {name}: {e}")
# ── Transition Validation ──────────────────────
lines.append("" * 50)
lines.append(" TRANSITION VALIDATION (Regime Stability)")
lines.append("" * 50)
try:
t_report = self.transition_validator.validate_from_db()
lines.append(t_report.summary())
except Exception as e:
logger.warning(f"Transition validation failed: {e}")
# ── Summary ────────────────────────────────────
lines.append("")
lines.append("=" * 70)
lines.append(" SUMMARY")
lines.append("=" * 70)
# Factor ranking by IC
if factor_reports:
ranked = sorted(factor_reports, key=lambda r: abs(r.ic_mean), reverse=True)
lines.append(" Factor Ranking (by |IC|):")
for i, r in enumerate(ranked):
tag = "★★★" if abs(r.ic_mean) > 0.05 else "★★" if abs(r.ic_mean) > 0.03 else ""
lines.append(f" {i+1}. {r.factor_name:20s} IC={r.ic_mean:+.4f} {tag} {r.conclusion}")
# Regime factor ranking
if regime_reports:
ranked_r = sorted(regime_reports, key=lambda r: r.separation_score, reverse=True)
lines.append("")
lines.append(" Regime Factor Ranking (by Separation Score):")
for i, r in enumerate(ranked_r):
lines.append(f" {i+1}. {r.factor_name:20s} Score={r.separation_score:.2f} {r.conclusion}")
lines.append("")
lines.append("=" * 70)
return "\n".join(lines)
@@ -1,131 +0,0 @@
"""
validation/transition_validator.py Validates regime stability.
Tests: Transition matrix, average duration, flip rate, state entropy.
Answers: "Does the regime design produce stable, persistent states?"
Hard requirements:
- avg_duration > 5 days
- flip_rate < 15%
- Fails regime definition needs redesign.
"""
from typing import Optional
import sqlite3
import logging
import numpy as np
import pandas as pd
from config import config
from .metrics import transition_matrix, regime_duration_stats
logger = logging.getLogger(__name__)
class TransitionReport:
"""Structured report for regime stability validation."""
def __init__(self):
self.avg_duration: float = 0.0
self.flip_rate: float = 0.0
self.state_entropy: float = 0.0
self.n_days: int = 0
self.transition_matrix: Optional[pd.DataFrame] = None
self.persistence_score: float = 0.0
self.is_stable: bool = False
self.conclusion: str = ""
self.warnings: list[str] = []
def summary(self) -> str:
lines = [
f"Regime Stability (N={self.n_days} days)",
f" Avg Duration: {self.avg_duration:.1f} days (need > {config.regime_min_avg_duration})",
f" Flip Rate: {self.flip_rate:.1%} (need < {config.regime_max_flip_rate:.0%})",
f" State Entropy: {self.state_entropy:.3f}",
f" Persistence Score: {self.persistence_score:.2f}",
f" Stable: {'YES' if self.is_stable else 'NO — redesign needed'}",
]
if self.warnings:
lines.append(f" Warnings: {'; '.join(self.warnings)}")
if self.transition_matrix is not None:
lines.append(f" Transition Matrix:\n{self.transition_matrix.to_string()}")
lines.append(f"{self.conclusion}")
return "\n".join(lines)
class TransitionValidator:
"""
Validates regime temporal stability.
Regime must persist not flip daily.
If flip_rate > 20% or avg_duration < 3 days regime definition failed.
"""
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path or config.db_path
def validate(self, regime_labels: pd.Series) -> TransitionReport:
"""Validate a regime sequence for stability."""
report = TransitionReport()
report.n_days = len(regime_labels)
if len(regime_labels) < 30:
report.conclusion = "INSUFFICIENT DATA (< 30 days)"
return report
# Duration stats
stats = regime_duration_stats(regime_labels)
report.avg_duration = stats["avg_duration"]
report.flip_rate = stats["flip_rate"]
report.state_entropy = stats["state_entropy"]
# Transition matrix
report.transition_matrix = transition_matrix(regime_labels)
# Persistence: how often does regime stay the same?
diag = np.diag(report.transition_matrix.values)
report.persistence_score = round(float(np.mean(diag)), 2)
# Stability check
report.is_stable = (
report.avg_duration >= config.regime_min_avg_duration and
report.flip_rate <= config.regime_max_flip_rate
)
# Warnings
if report.avg_duration < 3:
report.warnings.append(f"CRITICAL: avg duration={report.avg_duration:.1f}d — regime flips too fast")
elif report.avg_duration < config.regime_min_avg_duration:
report.warnings.append(f"WARNING: avg duration={report.avg_duration:.1f}d < {config.regime_min_avg_duration}")
if report.flip_rate > 0.20:
report.warnings.append(f"CRITICAL: flip rate={report.flip_rate:.1%} — regime unstable")
elif report.flip_rate > config.regime_max_flip_rate:
report.warnings.append(f"WARNING: flip rate={report.flip_rate:.1%} > {config.regime_max_flip_rate:.0%}")
if report.state_entropy > 2.0:
report.warnings.append(f"NOTE: high state entropy={report.state_entropy:.2f}, regimes may be too fine-grained")
if report.is_stable:
report.conclusion = "PASS: regime design is stable"
else:
report.conclusion = "FAIL: regime definition needs adjustment"
return report
def validate_from_db(self) -> TransitionReport:
"""Load regime history from DB and validate stability."""
conn = sqlite3.connect(self.db_path)
df = pd.read_sql_query(
"SELECT date, regime FROM regime_history ORDER BY date", conn
)
conn.close()
if df.empty:
r = TransitionReport()
r.conclusion = "NO DATA"
return r
regimes = df.set_index("date")["regime"]
return self.validate(regimes)
-178
View File
@@ -1,178 +0,0 @@
"""
web/app.py ChanMacro dashboard (Flask, port 8124).
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import json
from datetime import date as Date
from flask import Flask, render_template, jsonify, request
from database import get_connection
from config import config
from scoring.price_structure import PriceStructureScorer
from scoring.breadth_scorer import BreadthScorer
from scoring.oi_matrix import OIMatrixScorer
from scoring.volatility_regime import VolatilityRegimeScorer
from regime_detector import RegimeDetector
from models import MarketStateVector
from expectancy.engine import BayesianExpectancyEngine
app = Flask(__name__)
def _build_state(target: Date):
"""Build MarketStateVector and persist regime to DB."""
ps = PriceStructureScorer().compute(target)
br = BreadthScorer().compute(target)
oi = OIMatrixScorer().compute(target)
vol = VolatilityRegimeScorer().compute(target)
detector = RegimeDetector()
detector.load_state(config.db_path)
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, target)
state = MarketStateVector(
date=target, regime=r.regime, regime_confidence=r.confidence,
regime_version=r.regime_version, regime_maturity_score=r.maturity_score,
breadth_top20=br.breadth_top20, breadth_top30=br.breadth_top30,
breadth_top50=br.breadth_top50, breadth_bucket=br.breadth_bucket,
breadth_divergence=br.breadth_divergence,
oi_state=oi.oi_state, volatility_regime=vol.vol_regime,
price_structure_score=ps, breadth_score=br,
oi_matrix_score=oi, volatility_regime_score=vol,
)
state.market_state_hash = state.compute_hash()
# Persist regime to DB so load_state() works across requests
conn = get_connection()
conn.execute("""
INSERT OR REPLACE INTO regime_history
(date, regime, confidence, regime_version, maturity_score, all_scores_json,
prior_regime, confirmation_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
str(target), r.regime.value, r.confidence, r.regime_version,
r.maturity_score, json.dumps(r.all_scores),
r.prior_regime.value if r.prior_regime else None,
r.confirmation_days,
))
conn.commit()
conn.close()
return state
@app.route("/")
def dashboard():
return render_template("index.html")
@app.route("/api/state")
def api_state():
"""Current market state with all factor scores."""
try:
target = Date.today()
state = _build_state(target)
return jsonify({
"date": str(state.date),
"regime": state.regime.value,
"regime_confidence": state.regime_confidence,
"regime_maturity": state.regime_maturity_score,
"breadth": {
"score": state.breadth_score.score,
"bucket": state.breadth_bucket.value,
"top20": state.breadth_top20,
"top30": state.breadth_top30,
"top50": state.breadth_top50,
"divergence": state.breadth_divergence,
"narrative": state.breadth_score.narrative,
},
"oi_state": state.oi_state.value,
"oi_score": state.oi_matrix_score.score,
"oi_narrative": state.oi_matrix_score.narrative,
"volatility": state.volatility_regime.value,
"price_structure": {
"score": state.price_structure_score.score,
"trend": state.price_structure_score.trend_strength,
"vol_comp": state.price_structure_score.volatility_compression,
"momentum": state.price_structure_score.momentum,
"label": state.price_structure_score.label,
"narrative": state.price_structure_score.narrative,
},
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/history")
def api_history():
"""Regime and factor score history."""
days = request.args.get("days", 60, type=int)
conn = get_connection()
# Regime history
regimes = conn.execute(
"SELECT date, regime, confidence, maturity_score FROM regime_history ORDER BY date DESC LIMIT ?",
(days,)
).fetchall()
# Breadth history
breadth = conn.execute(
"SELECT date, advance_top50, decline_top50, above_ema20_top50 FROM breadth_daily ORDER BY date DESC LIMIT ?",
(days,)
).fetchall()
conn.close()
return jsonify({
"regimes": [{"date": r["date"], "regime": r["regime"],
"confidence": r["confidence"], "maturity": r["maturity_score"]}
for r in reversed(regimes)],
"breadth": [{"date": b["date"], "advance": b["advance_top50"],
"decline": b["decline_top50"], "above_ema20": b["above_ema20_top50"]}
for b in reversed(breadth)],
})
@app.route("/api/expectancy")
def api_expectancy():
"""Query signal expectancy."""
signal = request.args.get("signal", "B3")
try:
target = Date.today()
state = _build_state(target)
engine = BayesianExpectancyEngine(level_min_samples=5)
report = engine.estimate(state, signal_type=signal, target_date=target)
layers = []
for l in report.layers:
layers.append({
"name": l.name,
"samples": l.samples,
"effective_samples": l.effective_samples,
"raw_winrate": l.raw_winrate,
"posterior_winrate": l.posterior_winrate,
"avg_return": l.avg_return,
})
return jsonify({
"signal": signal,
"final_estimate": report.final_estimate,
"sufficiency": report.sufficiency.value,
"source": report.source,
"avg_return_7d": report.avg_return_7d,
"profit_factor": report.profit_factor,
"max_adverse": report.max_adverse_excursion,
"layers": layers,
})
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == "__main__":
from scheduler import get_scheduler
get_scheduler().start()
app.run(host="0.0.0.0", port=8124, debug=True)
-160
View File
@@ -1,160 +0,0 @@
// dashboard.js — ChanMacro
const C = { TREND: "#3fb950", RANGE: "#d29922", PANIC: "#f85149" };
let regimeChart = null, breadthChart = null;
async function loadState() {
try {
const r = await fetch("/api/state");
const d = await r.json();
if (d.error) { document.getElementById("update-time").textContent = d.error; return; }
document.getElementById("update-time").textContent = d.date;
// Hero
const regime = d.regime;
const names = { TREND: "TREND", RANGE: "RANGE", PANIC: "PANIC" };
document.getElementById("hero-regime").textContent = names[regime] || regime;
document.getElementById("hero-regime").className = "regime-name " + regime.toLowerCase();
document.getElementById("hero-badge").textContent = regime;
document.getElementById("hero-badge").className = "regime-badge " + regime.toLowerCase();
document.getElementById("hero-conf").textContent = (d.regime_confidence * 100).toFixed(0) + "%";
document.getElementById("hero-maturity").textContent = d.regime_maturity.toFixed(0);
document.getElementById("hero-ps").textContent = d.price_structure.score.toFixed(0);
document.getElementById("hero-ps").style.color =
d.price_structure.score >= 60 ? "#3fb950" : d.price_structure.score >= 40 ? "#d29922" : "#f85149";
document.getElementById("hero-br").textContent = d.breadth.score.toFixed(0);
document.getElementById("hero-br").style.color =
d.breadth.bucket === "EXTREME" || d.breadth.bucket === "STRONG" ? "#3fb950" :
d.breadth.bucket === "WEAK" || d.breadth.bucket === "PANIC" ? "#f85149" : "#d29922";
// Factor cards
const ps = d.price_structure;
document.getElementById("f-price").textContent = ps.score.toFixed(0);
document.getElementById("f-price").style.color =
ps.score >= 60 ? "#3fb950" : ps.score >= 40 ? "#d29922" : "#f85149";
document.getElementById("f-price-sub").textContent =
`趋势 ${ps.trend.toFixed(0)} · 波动 ${ps.vol_comp.toFixed(0)} · 动量 ${ps.momentum.toFixed(0)}`;
document.getElementById("bar-price").style.width = ps.score + "%";
document.getElementById("bar-price").style.background =
ps.score >= 60 ? "#3fb950" : ps.score >= 40 ? "#d29922" : "#f85149";
const br = d.breadth;
document.getElementById("f-breadth").textContent = br.score.toFixed(0);
document.getElementById("f-breadth").style.color =
br.bucket === "EXTREME" || br.bucket === "STRONG" ? "#3fb950" :
br.bucket === "WEAK" || br.bucket === "PANIC" ? "#f85149" : "#d29922";
document.getElementById("f-breadth-sub").textContent =
`${br.bucket} · T20=${br.top20.toFixed(0)} T50=${br.top50.toFixed(0)}`;
document.getElementById("bar-breadth").style.width = br.score + "%";
document.getElementById("bar-breadth").style.background =
br.bucket === "EXTREME" || br.bucket === "STRONG" ? "#3fb950" :
br.bucket === "WEAK" || br.bucket === "PANIC" ? "#f85149" : "#d29922";
document.getElementById("f-oi").textContent = d.oi_state.toUpperCase().replace(" ", "\n");
document.getElementById("f-oi").style.color =
d.oi_state === "New Longs" ? "#3fb950" : d.oi_state.includes("Short") || d.oi_state === "Long Exit" ? "#f85149" : "#8b949e";
document.getElementById("f-oi-sub").textContent = d.oi_narrative;
const vm = { LOW_VOL: "低波动", NORMAL_VOL: "正常", HIGH_VOL: "高波动", EXPLOSIVE_VOL: "极端" };
document.getElementById("f-vol").textContent = vm[d.volatility] || d.volatility;
document.getElementById("f-vol").style.color =
d.volatility === "LOW_VOL" ? "#58a6ff" : d.volatility === "NORMAL_VOL" ? "#8b949e" :
d.volatility === "HIGH_VOL" ? "#d29922" : "#f85149";
document.getElementById("f-vol-sub").textContent = d.volatility;
document.getElementById("bar-vol").style.width =
(d.volatility === "EXPLOSIVE_VOL" ? 95 : d.volatility === "HIGH_VOL" ? 70 :
d.volatility === "NORMAL_VOL" ? 40 : 20) + "%";
document.getElementById("bar-vol").style.background =
d.volatility === "EXPLOSIVE_VOL" ? "#f85149" : d.volatility === "HIGH_VOL" ? "#d29922" :
d.volatility === "NORMAL_VOL" ? "#8b949e" : "#58a6ff";
} catch (e) {
document.getElementById("update-time").textContent = "连接失败";
}
}
async function loadHistory() {
try {
const r = await fetch("/api/history?days=60");
const d = await r.json();
const dates = d.regimes.map(x => x.date);
const colors = d.regimes.map(x => C[x.regime] || "#5c6675");
if (regimeChart) regimeChart.destroy();
regimeChart = new Chart(document.getElementById("chart-regime").getContext("2d"), {
type: "bar",
data: { labels: dates, datasets: [{ data: d.regimes.map(x => x.confidence * 100),
backgroundColor: colors, borderWidth: 0, borderRadius: 2 }] },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: "#5c6675", maxTicksLimit: 15, maxRotation: 45, font: { size: 10 } },
grid: { color: "#151a23" } },
y: { max: 100, ticks: { color: "#5c6675", font: { size: 10 } }, grid: { color: "#151a23" } }
}
}
});
if (breadthChart) breadthChart.destroy();
breadthChart = new Chart(document.getElementById("chart-breadth").getContext("2d"), {
type: "line",
data: {
labels: d.breadth.map(x => x.date),
datasets: [
{ label: "上涨", data: d.breadth.map(x => x.advance), borderColor: "#3fb950",
backgroundColor: "rgba(63,185,80,0.08)", fill: true, tension: 0.3, pointRadius: 0 },
{ label: "下跌", data: d.breadth.map(x => x.decline), borderColor: "#f85149",
backgroundColor: "rgba(248,81,73,0.06)", fill: true, tension: 0.3, pointRadius: 0 },
{ label: ">EMA20", data: d.breadth.map(x => x.above_ema20), borderColor: "#58a6ff",
borderDash: [3, 3], tension: 0.3, pointRadius: 0 },
]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: "#5c6675", usePointStyle: true, boxWidth: 6, font: { size: 10 } } } },
scales: {
x: { ticks: { color: "#5c6675", maxTicksLimit: 15, maxRotation: 45, font: { size: 10 } },
grid: { color: "#151a23" } },
y: { ticks: { color: "#5c6675", font: { size: 10 } }, grid: { color: "#151a23" } }
}
}
});
} catch (e) { console.error(e); }
}
async function loadExpectancy() {
const signal = document.getElementById("exp-signal").value;
try {
const r = await fetch(`/api/expectancy?signal=${signal}`);
const d = await r.json();
if (d.error) { document.getElementById("exp-layers").innerHTML =
`<tr><td colspan="6" style="color:#f85149">${d.error}</td></tr>`; return; }
const el = document.getElementById("exp-sufficiency");
el.textContent = d.sufficiency;
el.className = "suff suff-" + d.sufficiency;
let html = "";
for (const l of d.layers) {
html += `<tr>
<td>${l.name}</td><td>${l.samples}</td><td>${l.effective_samples.toFixed(0)}</td>
<td>${l.raw_winrate ? (l.raw_winrate * 100).toFixed(1) + "%" : "—"}</td>
<td><strong>${(l.posterior_winrate * 100).toFixed(1)}%</strong></td>
<td style="color:${l.avg_return > 0 ? '#3fb950' : l.avg_return < 0 ? '#f85149' : '#8b949e'}">${l.avg_return ? (l.avg_return > 0 ? "+" : "") + l.avg_return.toFixed(2) + "%" : "—"}</td>
</tr>`;
}
document.getElementById("exp-layers").innerHTML = html;
let s = `后验胜率 <strong style="color:#58a6ff">${(d.final_estimate * 100).toFixed(1)}%</strong>`;
if (d.avg_return_7d) s += ` · 平均收益 <strong>${d.avg_return_7d > 0 ? "+" : ""}${d.avg_return_7d.toFixed(2)}%</strong>`;
if (d.profit_factor) s += ` · 盈亏比 <strong>${d.profit_factor}</strong>`;
if (d.max_adverse) s += ` · MAE <strong>${d.max_adverse.toFixed(1)}%</strong>`;
document.getElementById("exp-summary").innerHTML = s;
} catch (e) { console.error(e); }
}
loadState();
loadHistory();
loadExpectancy();
-163
View File
@@ -1,163 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChanMacro — 市场状态</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0e14; color: #c9d1d9; font-family: -apple-system, BlinkMacSystemFont, "SF Mono", monospace; }
.app { max-width: 1200px; margin: 0 auto; padding: 20px 24px; }
/* Header */
.header { display: flex; justify-content: space-between; align-items: flex-end; padding: 20px 0 28px;
border-bottom: 1px solid #1c2333; margin-bottom: 24px; }
.header h1 { font-size: 22px; font-weight: 600; letter-spacing: 1px; }
.header h1 span { color: #58a6ff; }
.header .time { color: #5c6675; font-size: 13px; }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #3fb950;
margin-right: 6px; animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
/* Regime Hero */
.hero { display: flex; gap: 16px; margin-bottom: 24px; }
.hero-card { flex: 1; background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 20px 24px; }
.hero-card.main { flex: 2; display: flex; align-items: center; gap: 28px; }
.regime-badge { display: inline-block; padding: 5px 16px; border-radius: 4px; font-size: 13px;
font-weight: 600; letter-spacing: 2px; }
.regime-badge.trend { background: rgba(63,185,80,0.12); color: #3fb950; border: 1px solid rgba(63,185,80,0.3); }
.regime-badge.range { background: rgba(210,153,34,0.12); color: #d29922; border: 1px solid rgba(210,153,34,0.3); }
.regime-badge.panic { background: rgba(248,81,73,0.12); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
.regime-name { font-size: 42px; font-weight: 700; letter-spacing: 2px; }
.regime-name.trend { color: #3fb950; }
.regime-name.range { color: #d29922; }
.regime-name.panic { color: #f85149; }
.hero-stat { text-align: center; }
.hero-stat .val { font-size: 28px; font-weight: 600; color: #e6edf3; }
.hero-stat .lbl { font-size: 11px; color: #5c6675; letter-spacing: 1px; margin-top: 4px; }
/* Factor Grid */
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
.fcard { background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 18px 20px; }
.fcard .title { font-size: 11px; color: #5c6675; letter-spacing: 1.5px; margin-bottom: 10px; }
.fcard .score { font-size: 38px; font-weight: 700; margin-bottom: 4px; }
.fcard .sub { font-size: 12px; color: #5c6675; }
.fcard .bar-wrap { height: 3px; background: #1c2333; border-radius: 2px; margin-top: 12px; }
.fcard .bar { height: 100%; border-radius: 2px; transition: width 0.6s; }
/* Charts */
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px; }
.chart-box { background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 18px 20px; }
.chart-box h3 { font-size: 12px; color: #5c6675; letter-spacing: 1.5px; margin-bottom: 14px; }
.chart-box canvas { max-height: 260px; }
/* Expectancy */
.exp { background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 18px 20px; }
.exp h3 { font-size: 12px; color: #5c6675; letter-spacing: 1.5px; margin-bottom: 14px; }
.exp-row { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; }
.exp select { background: #0a0e14; color: #c9d1d9; border: 1px solid #1c2333; padding: 6px 12px;
border-radius: 4px; font-size: 13px; }
.exp button { background: #1c3a5c; color: #58a6ff; border: 1px solid #2d4f7c; padding: 6px 18px;
border-radius: 4px; cursor: pointer; font-size: 13px; }
.exp button:hover { background: #254d7a; }
.exp .suff { font-size: 11px; padding: 3px 10px; border-radius: 3px; }
.suff-HIGH { background: rgba(63,185,80,0.12); color: #3fb950; }
.suff-MEDIUM { background: rgba(210,153,34,0.12); color: #d29922; }
.suff-LOW { background: rgba(248,81,73,0.12); color: #f85149; }
.suff-INSUFFICIENT { background: rgba(92,102,117,0.12); color: #5c6675; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; color: #5c6675; font-weight: 500; padding: 8px 10px; border-bottom: 1px solid #1c2333; }
td { padding: 7px 10px; border-bottom: 1px solid #0e1219; color: #8b949e; }
td strong { color: #e6edf3; }
.exp-summary { margin-top: 14px; font-size: 13px; color: #8b949e; padding: 10px 14px;
background: #0d1117; border-radius: 6px; border-left: 3px solid #58a6ff; }
.exp-summary strong { color: #e6edf3; }
</style>
</head>
<body>
<div class="app">
<!-- Header -->
<div class="header">
<div>
<h1><span>Chan</span>Macro</h1>
</div>
<div class="time"><span class="dot"></span> <span id="update-time">加载中...</span></div>
</div>
<!-- Regime Hero -->
<div class="hero">
<div class="hero-card main">
<div>
<div class="regime-badge" id="hero-badge"></div>
<div class="regime-name" id="hero-regime"></div>
</div>
<div style="display:flex; gap:32px; margin-left:auto;">
<div class="hero-stat"><div class="val" id="hero-conf"></div><div class="lbl">置信度</div></div>
<div class="hero-stat"><div class="val" id="hero-maturity"></div><div class="lbl">成熟度</div></div>
</div>
</div>
<div class="hero-card" style="flex:1">
<div class="hero-stat"><div class="val" id="hero-ps"></div><div class="lbl">价格结构</div></div>
</div>
<div class="hero-card" style="flex:1">
<div class="hero-stat"><div class="val" id="hero-br"></div><div class="lbl">市场广度</div></div>
</div>
</div>
<!-- 4 Factor Cards -->
<div class="grid">
<div class="fcard">
<div class="title">价格结构 PRICE STRUCTURE</div>
<div class="score" id="f-price"></div>
<div class="sub" id="f-price-sub"></div>
<div class="bar-wrap"><div class="bar" id="bar-price"></div></div>
</div>
<div class="fcard">
<div class="title">市场广度 BREADTH</div>
<div class="score" id="f-breadth"></div>
<div class="sub" id="f-breadth-sub"></div>
<div class="bar-wrap"><div class="bar" id="bar-breadth"></div></div>
</div>
<div class="fcard">
<div class="title">持仓状态 OI MATRIX</div>
<div class="score" id="f-oi" style="font-size:24px"></div>
<div class="sub" id="f-oi-sub"></div>
</div>
<div class="fcard">
<div class="title">波动率 VOLATILITY</div>
<div class="score" id="f-vol"></div>
<div class="sub" id="f-vol-sub"></div>
<div class="bar-wrap"><div class="bar" id="bar-vol"></div></div>
</div>
</div>
<!-- Charts -->
<div class="charts">
<div class="chart-box"><h3>制度历史 REGIME HISTORY</h3><canvas id="chart-regime"></canvas></div>
<div class="chart-box"><h3>市场广度 BREADTH</h3><canvas id="chart-breadth"></canvas></div>
</div>
<!-- Expectancy -->
<div class="exp">
<h3>信号期望 SIGNAL EXPECTANCY</h3>
<div class="exp-row">
<select id="exp-signal">
<option value="B3">B3 · 三买</option><option value="B2">B2 · 二买</option><option value="B1">B1 · 一买</option>
<option value="S3">S3 · 三卖</option><option value="S2">S2 · 二卖</option><option value="S1">S1 · 一卖</option>
</select>
<button onclick="loadExpectancy()">查询</button>
<span class="suff" id="exp-sufficiency"></span>
</div>
<table>
<thead><tr><th>层级</th><th>样本</th><th>有效样本</th><th>原始胜率</th><th>后验胜率</th><th>平均收益</th></tr></thead>
<tbody id="exp-layers"></tbody>
</table>
<div class="exp-summary" id="exp-summary"></div>
</div>
</div>
<script src="/static/js/dashboard.js"></script>
</body>
</html>
-239
View File
@@ -1,239 +0,0 @@
均线
5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 16h, 1d, 2d, 3d, 1w, 2w, 1M
参考时间周期
大周期:1h
小周期:15m
价格在1h周期ema156之上为大周期上涨,反之为大周期下跌
在1h大周期上涨时,小周期15m,下跌触碰到
顺大逆小
大周期看多,小周期跌完做多,跌完:顶分型和EMA52归零轴反弹
大周期看空,小周期涨完做空,涨完:顶分型和EMA52归零轴反抽
中枢分类
常规中枢
上升中枢
收敛中枢
扩散中枢
下行中枢
止损放到顶底分型的高低点
1. 从大周期开始找到价格接近ema52,MACD也接近零轴的周期,需要看这个周期的长级别是否高位空,大趋势方向
2. 然后去小于这个时间周期的周期找买卖点,小级趋势方向和大趋势相反并且开始反向,小级别需要检查MACD是否归零轴反转,同时价格是否接近EMA52
EMA52线的反弹比零轴的反弹弱
EMA52线和MACD白线同时归零轴同时满足的话是完美形态,最佳买卖点
跟随策略,先调整小级别,然后依次往大级别调整,直到整个周期结束
大级别MACD在零轴之上为多头趋势,回调踩EMA52做多,直到跳空背离,隐形,更大级别EMA52顶部归零轴平仓
大级别MACD在零轴之下为空头趋势,上涨踩EMA52做空,直到跳空背离,隐形,更大级别EMA52底部归零轴平仓
盘整趋势在零轴上下移动,价格在大级别EMA52之间移动,根据连续跳空背离,隐形,归零轴EMA52线开仓和平仓
MACD归零轴的两种情况,两者是或的关系,满足任意一种都是归零轴,归零轴的四种走势:
1. K线下跌或者上涨后触碰当前时间级别的EMA52附近
2. MACD的白线快线无限接近零轴,
3. MACD归零轴时,如果此时k线始终保持在EMA24附近,如果一直是EMA24之上之后出现反弹行情就会很大(最强反弹)这种反弹是2个时间级别同时归零轴形成的反弹,容易创新高新低,一般出现在强势行情。
4. K线先触碰EMA52,而MACD黄白线都未归零轴
MACD归零轴反弹/反抽的完美形态是K线触碰EMA52附近,MACD的白线无限接近零轴之后出现上涨或下跌
高位空
当MACD的黄白线远离零轴运行时,与零轴有一定的距离,形成了零轴的高危形态。随着K线出现缓慢上涨或者下跌,或者盘整,MACD的能量柱出现衰减,同时能量柱与MACD黄白线形成空间夹角,随着能量柱越来越小,夹角越来越大形成高位空。这种容易形成回调下跌,特别是导致次一级的MACD穿越零轴
穿越零轴的定义,需要同时满足以下条件
1. 在某个时间级别,k线的价格或者指数有效击穿当前时间级别的EMA52
2. MACD黄线慢线有效击穿零轴
MACD黄白线和零轴的几种形态:
离开零轴
当MACD黄白线穿过零轴那么进入第一阶段离开零轴,此时能量柱变化越来越大,不断增长,k线加速上涨
高位
当MACD黄白线离开零轴,到一高点时,能量柱此时处于最大,开始减弱时MACD处于高位,高位时MACD黄白线和能量柱是同向的,高位过后是高位空
高位空
当MACD黄白线处于高位,随着K线出现缓慢上涨或者横盘整理,MACD黄白线保持高位出现平滑横盘走势,此时,MACD的能量柱出现衰减变化,同时能量柱和黄白线之间形成一定的空间夹脚,随着能量柱的不断衰减就导致黄白线和能量柱之间的空间夹脚越来越大,因此就形成高位空
归零轴
当MACD黄白线在高位,驱动K线上涨的能量所产生的加速度小于或者等于零,K线减速上涨或者下跌,能量变化越来越小,能量柱呈现出一根比一根短的排列方式
穿零轴
同时满足以下两个条件
1. 在某个时间级别,K线的价格或者指数有效击穿当前时间级别的EMA52
2. 当前时间级别MACD的黄线慢线有效击穿零轴
有效的定义是:当前K线正好击穿EMA52的支撑位后,如果当前这个K线收盘后的第二根K线任然保持在EMA52之下才算有效击穿,如果只是上下影线击穿,后期K线任然运行在EMA52之上不算有效击穿,MACD同理
零轴缠绕/纠缠
具体是指MACD跟零轴无限接近或者缠绕的状态,或者是已经完成第一次归零轴调整之后,在等待大级别调整的时候。
分为无限接近和上下缠绕状态。代表本级别已经调整完毕,即不产生反弹支撑,也不形成阻力压力,不考虑次级别的技术形态,通过更大的时间级别或者其他时间级别进行分析
隐形形态
当MACD的黄白线发生交叉时,必有相应的能量柱释放出来。金叉,则会释放零轴之上的能量柱,反之死叉,则会释放出零轴之下的能量柱。如果黄白线无交叉,而k线出现上涨或者下跌,则代表能量柱的隐形状态,代表K线的运行无能量配合,那么这种上涨或者下跌就变成无效的结果。无能量配合的上涨必下跌,无能量配合的下跌必反弹
1. 远离零轴的高位隐形形态
某个时间级别的MACD黄白线处在远离零轴的高位,且K线继续拉升上涨或者下跌,但是并没有释放出相对应方向的能量柱,此后,K线将出现归零轴的走势下跌或者上涨。此形态是高位隐形形态+高位空的形态,则当前级别的MACD在后续走势中必将出现会拉零轴甚至穿零轴的走势。因此,远离零轴的高位隐形形态解决的是当前时间级别归零轴的需求。
2. 归零轴的隐形形态
归零轴后反弹出现的隐形形态,我们称之为归零轴的隐形形态。这种形态必然会导致当前级别的MACD黄白线出现穿零轴的走势。MACD在归零轴的情况下,零轴所提供的反弹或者支撑能量是最大的,而如果零轴所能提供的最大能量都产生不了相应的能量柱,这种支撑就变成了无效的支撑,MACD的黄白线就只能击穿零轴渠道零轴的反方向。
如何确认隐形形态的顶部
1. 通过顶底分型来确认
2. 通过次级别的背离确认隐形高位
3. 通过单位调整周期之内的时间级别嵌套逻辑确认隐形的高位
顶底分型在K线动能理论的应用
1. 分型对应的MACD处在高位空的形态
2. 分型所对应的MACD出现隐形形态
零轴之上高位隐形 + K线顶分型 = 下跌归零轴
零轴之上归零轴隐形 + K线顶分型 = 下跌穿零轴
零轴之下高位隐形 + K线底分型 = 上涨归零轴
零轴之下归零轴隐形 + K线底分型 = 上涨穿零轴
K线的3种盘整结构,上涨和下跌均适用,下面是上涨结构的分析,下跌反之
1. K线强势的走势结构
一般应用是在单边上涨行情中,某个时间级别的K线经过一轮拉升之后,进入调整的阶段,此时K线如果在高位一直处于横盘震荡走势,而MACD的黄白线却趋于归零轴运行,则当黄白线归零轴之后,出现有效反弹行情。
2. K线超强势结构
超强势结构往往容易发生破前高的走势。在某个时间级别,当K线经过一轮强势拉升上涨后,变为倾斜向上缓慢上行,K线一直处于这个时间级别的EMA24之上或者附近,经过一段时间的运行,导致当前级别的MACD黄白线无限归零轴的形态,此时,K线往往容易出现速度快,力度强且破前高的走势。对于超强势调整结构,最重要的是次级别不破零轴,并保持在当前级别的EMA24之上或者附近,而当前级别的黄白线运动一段时间后无限趋于零轴。一般来说,超强势调整结构容易出现在某个大的时间级别处于单边行情中。
3. 弱势调整结构
在弱势调整结构中,K线是通过下跌的方式快速地将当前级别的MACD的黄白线拉回零轴,同时,K线的价格也会快速下跌到本级别EMA52附近位置。在弱势调整结构中,MACD归零轴后所形成的支撑反弹往往不会破前高,而是走出能量不足的走势形态。在此结构中,只有MACD的黄白线出现死叉后才会继续下跌,并且只有在弱势调整结构中,死叉下跌才有效。
单位调整周期
指K线在某个时间级别,MACD的黄白线由零轴出发到远离零轴再到回到零轴的区间段称为这个时间级别的调整周期。可以分为零轴同方向和穿零轴出发两种。一个单位调整周期的起点往往是买点,同时终点也是另一个周期的买点。单位周期的判断依据是黄白线归零轴不是量能柱的多少。
注意:如果单位调整周期的起始和终止都直接穿零轴的,代表这个时间级别在运行的过程中,是无效的时间级别,也就是这个时间级别在我们的分析的过程中要跳过的。
隐形单位调整周期
MACD黄白线归零轴后,由于零轴的支撑或者压力而发生的反弹或者反抽,没有释放出相应的能量柱而形成的周期,为隐形单位调整周期。隐形单位调整周期会引起黄白线反向穿零轴的走势。
零轴粘合
MACD黄白线在刚穿零轴的时候会出现:黄白线离零轴的距离比较近,黄白线沿着能量柱运行,黄白线在运行的过程中没有释放出反向能量柱。零轴粘合属性是:强支撑,弱反弹。这种形态我们更强调支撑能量,弱化反弹属性。零轴粘合几乎是贴近零轴运行,因此其反弹的动能就是为无效。斜率小,黄白线喝能量柱之间基本没有空隙。能量柱可以略微减弱但是不能释放下跌方向的能量柱。由此可见黄线没有跟白线有交叉,黄线对白线有支撑作用。如果当前时间级别内部逻辑关系走完,这个时间级别则被视为无效时间级别。
零轴倒挂
MACD黄白线在穿零轴的时候与零轴的距离比较近,同时黄白线沿着能量柱运行,在运行的过程中,能量柱衰减导致它跟黄白线之间形成夹角空位,同时黄白线产生交叉并释放反向能量柱。
1. 黄白线穿零轴后未远离零轴形成一定高度,而是靠近零轴运行
2. 能量柱的衰减导致其与黄白线之间形成了一定的夹角空位
3. 黄白线在运行的过程中发生了交叉而放出反向能量柱
弱支撑,弱反弹。如果当前时间级别内部逻辑关系走完,这个时间级别则被视为无效时间级别。如果某个时间级别的盘口形态是零轴倒挂,那么这个时间级别很容易直接击穿零轴,而无法形成有效的反弹和反抽行情。
零轴粘合和倒挂的有效性
在上涨行情中,当K线处在零轴粘合或者零轴倒挂的形态时,如果K线的价格处在当前级别的EMA52之上或者处在多级别EMA均线交汇处之上和附近时,此时由于K线受到EMA均线的支撑,零轴粘合或者零轴倒挂反而容易形成强支撑的特点。此时需要结合MACD的形态和K线均线支撑综合分析盘面。
线段
上涨线段是指MACD的黄白线第一次上穿零轴到下一次下穿零轴中间的运行区域,以黄线穿零轴为准。在这个线段找到阶段性的卖点,阶段性高点。
下跌线段是指MACD的黄白线第一次下穿零轴到下一次上穿零轴中间的运行区域,以黄线穿零轴为准。在这个线段找到阶段性的买点,阶段性低点。
1. 同一条线段是比较背离的区域,背离的比较不可再跨线段的区域进行
2. 线段可以将不同时间级别的K线化繁为简,一个时间级别的形态只需要确定盘面所处的线段即可
背离
在K线分析中,背离是指价格跟能量之间的相悖性,当价格创出阶段性新高点,而推动价格上涨的能量出现衰减,这种情况就是背离,也就是说价格和能量之间产生了不匹配关系。
顶背离 - 确认卖点
在某个时间级别,MACD运行在零轴上方,当K线的价格走势一峰比一峰高,价格一直处在上涨趋势中时,MACD的黄白线或者能量柱的高度却一波比一波低,即当价格的高点比前一次价格的高点高,而MACD指标的高点比前一次高点低,这种形态称为顶背离形态。需要注意的是,这两次高点在运行的过程中,MACD的黄白线始终处在同一条上涨线段周期中,不能跨线段比较。
顶背离包括黄白线背离和柱背离两种情况,也可能出现多次背离的情况。线背离以白线的最高点作为参考点,K线上影线最高点作为K线的参考点,能量堆的最高点可能和K线的最高点不是一一对应,但是不影响判断,可以使用能量堆的面积进行计算。
底背离 - 确认买点
在某个时间级别,MACD运行在零轴下方,一般出现价格的低位区。当K线的价格走势持续下跌,而MACD的黄白线或者能量柱却持续靠近零轴,即当前价格的低点比前一次低点好要低,而MACD指标的低点却比前一次低点高,但是下跌能量却在衰减的现象,于是价格跌无可跌,是短期买入信号
底背离包括黄白线背离和柱背离两种情况,也可能出现多次背离的情况。线背离以白线的最低点作为参考点,K线上影线最低点作为K线的参考点,能量堆的最低点可能和K线的最低点不是一一对应,但是不影响判断,可以使用能量堆的面积进行计算。
顶底背离高低点有效性的方法
1. 通过顶底分型确认顶底背离的高低点
2. 通过次级别的背离确认当前级别的背离高点,这里的次级别不是单指一级次级别,可以是多级的
单位调整周期内的连续跳空背离
K线经过一波上涨或者下跌后,MACD的黄白线由高位回零轴且未归到零轴,能量柱在连续的衰减调整过程中,反而逐渐由衰减转为增长,于是就出现了跳空走势,这种走势称为MACD的连续跳空形态。
1. 连续跳空发生在单位调整周期内,线跟柱在零轴同方向
2. MACD的能量柱需包含在黄白线之内
3. 能量柱在衰减的过程中未放出反向能量柱,而是由衰减转为增长
单位调整周期之内的背离是价格跟能量柱之间的关系,同时单位调整周期之内的背离,解决的是归零轴的需求
连续跳空的应用和意义
单位调整周期之内,能量堆连接在一起的时候,出现的价格跟能量柱之间的关系即为连续跳空,而连续跳空的意义
1. 连续跳空背离解决归零轴的需求,主要是小级别的走势,比如5分钟的连续跳空背离则为归零轴走势,因为5分钟级别只包含一个3分钟级别,是5分钟内的小级别,因此,5分钟级别如果出现连续跳空背离,会导致5分钟级别MACD黄白线归零轴走势
2. 连续跳空更大的作用是确认穿零轴之后的第一个背离参考点,当MACD黄白线穿零轴的时候,最重要的是确认当前线段的1号参考点,有了1号参考点,后续行情才有参考的对象。连续跳空往往发生在MACD黄白线刚刚穿零轴的位置,一般由零轴粘合的形态演化而成连续跳空,这是因为零轴粘合具有强支撑的特点,容易形成跳空走势。
穿零轴时的参考点确认方法
1. 如果MACD黄白线穿零轴之后出现连续跳空,则以连续跳空的高点作为1号参考点,此时连续跳空形态代表新的单位周期调整周期的开始。当黄白线穿零轴后,黄线之后的第一个能量堆没有更高的能量柱,而是到了第一个跳空高低点出现第一个高低点。穿零轴产生的能量柱左侧黄白线处在下跌线段,右侧处在上涨线段。因此穿零轴的能量柱被切成两半,左侧在下跌线段,所以不能以黄线击穿零轴的左侧作为上涨线段的1号参考点。背离一定要在同一线段中进行比较,而不能跨线段找背离。那么穿零轴之后跳空产生的高低点才能作为1号参考点。后续的背离要以这个参考点进行比较。
2. 如果MACD黄白线穿零轴后无连续跳空,没有更高的能量柱出现则不能确定1号参考点,如果穿零轴后没有找到更高低的能量柱,那么这个线段的第一个单位调整周期是无效周期。
3. 如果黄线穿零轴之后有更高低的能量柱,则以更高的能量柱作为1号参考点,此参考点可以是穿零轴时的能量柱高点确定。
单位调整周期之内的分立跳空背离
在某个时间级别,当一个单位调整周期之内包含两个或者两个以上的能量堆,能量堆之间被反向能量堆分隔开,同时MACD黄白线一直处于远离零轴的高位,未归零轴,且一直保持原有的趋势运行,被分割的能量堆与黄白线都处在零轴的同方向,能量堆包含在黄白线之内,就形成分离跳空形态。
分立跳空背离
如果在某个时间级别的单位调整周期内,黄白线未归零轴,K线在经过一段时间的调整并放出反向能量柱之后,继续沿着原有方向运行,导致再一次出现的能量堆,同时黄白线再一次远离零轴,能量堆和能量堆之间形成背离关系,这种就是分离跳空背离。分立跳空背离解决的是归零轴的需求
分立跳空背离 + 黄白线高位 = 归零轴
分立跳空不背离 = 单边上涨或者下跌行情
最佳买卖点
单位周期之内 + 隐形 + 分立跳空 + 背离 + 黄白线高位空
跳空产生的原理:跳空的产生是当前级别之下的小级别归零轴反弹或反抽导致的,比如1小时时间级别的单位调整周期跳空,是因为1小时时间级别之内包含3分钟,5分钟,15分钟,30分钟这些下级别。1小时级别的MACD归零轴的途中,必然导致其内部的小级别先于本级别归零轴。因此,当这些小级别归零轴后,如果产生反弹反抽的走势,则会导致当前1小时级别的MACD黄白线再一次被拉高,此时便产生了跳空的走势。
时间级别在高位中归零轴的顺序是:由小级别到大级别依此归零轴。当K线经过一轮拉升下跌后,当前级别的MACD黄白线处在高位,此时如果K线进入调整状态,则这个时间级别所包含的小级别由小到大依次归零轴,直至本级别归零轴为止,如此才完成了次级别的单位调整周期的调整。
单位周期之内的分立跳空顶背离
MACD黄白线在零轴之上第一个单位调整周期 + 分立跳空背离(一次或多次) + 黄白线处在零轴的高位空 + 分立跳空隐形状态
单位周期之内的分立跳空底背离
MACD黄白线在零轴之下第一个单位调整周期 + 分立跳空背离(一次或多次) + 黄白线处在零轴的高位空 + 分立跳空隐形形态
单位调整周期之内的跳空非背离
如果在单位调整周期内发生了跳空的形态,当跳空能量堆的高度高于前一个能量堆的高度,此时的跳空则为非背离跳空。非背离跳空可以理解为单位周期的单边行情,前一个能量堆失效,以新的最高的能量堆作为后续行情的1号参考点。不管是连续跳空还是分立跳空都遵守这个法则。
单位调整周期之间的背离
单位调整周期之间的背离,是指两个或者两个以上的单位调整周期相比较而建立的关系,相比较的周期必须在同一个线段周期内,不可跨线段比较。当K线在某个时间级别的线段中,MACD经过了一个单位调整周期的运行,黄白线在此回零轴后,由于零轴的支撑反弹或压力反抽的作用,因此出现第二个单位调整周期,当第二个单位调整周期的黄白线这里特指白线离开零轴的距离小于第一个单位调整周期黄白线离开零轴的距离时,单位调整周期之间就产生了相悖的关系,即价格穿新高或新低,而白线能量区出现了减弱或增强,这种背离称为单位调整周期之间的背离,单位调整周期之间的背离也叫区间背离。
上涨线段单位调整周期之间的顶背离为卖点(前提:长级别MACD在高位)
下跌线段单位调整周期之间的底背离为买点(前提:长级别MACD在高位)
单位调整周期之间的背离,其核心的本质是描述某个时间级别在线段中的运行逻辑,即为线段完成调整的重要依据。
单位调整周期之间的背离是为了满足线段调整的需求
判断某个时间级别线段结束趋势的依据为:在某个时间级别的线段中,第二个单位调整周期与第一个单位调整周期之间产生背离关系,即为此线段终结的依据,而后出现的单位调整周期无论归零轴多少次,从能量产生的逻辑上都是依次减弱直至趋于零。
单位调整周期之间的背离而穿零轴变盘的依据是:当某个级别在线段中出现周期之间的背离形态,同时完成了线段的调整,但是其长级别MACD的黄白线处在高位空的形态时,当前级别的背离会导致穿零轴走势。
在K线的时间逻辑中,判断一个时间级别完成自己的当值任务的标准是:本级别在当前的线段中产生了周期间的背离关系,并趋向于零轴的调整。本级别是否穿零轴并非由本级别背离的属性决定,而是由长级别决定。因此,当本级别完成线段调整之后,就要看长级别处在什么形态之下,长级别的形态属性决定了后续的行情走势。
时间级别升级:是指当本级别在其线段中产生了背离关系而趋向于零轴,达到了平衡状态且保持在零轴之上(代表完成了其线段的调整任务),其长级别同时也处在归零轴的形态,此时当前级别即要发生时间级别升级。时间级别升级之后,本级别将会产生新的线段,之后本级别的线段关系则升级为线段与线段之间的关系。
确定时间级别升级的条件
1. 当前级别多次归零轴背离而完成了线段的调整,之后新的单位调整周期MACD的白线DIF比前一个单位调整周期的白线DIF高
2. 当前级别完成线段调整,长级别MACD的黄白线无限接近零轴
线段背离
当某个时间级别升级之后,便产生了一条新的线段,如果线段和线段之间构成相悖关系时,我们称为线段背离,而线段背离则必然导致当前时间级别穿零轴。线段背离比较的是两个线段中最高或最低的白线DIF。
动能不足
如果K线走势中不破前高点上涨动能衰减,或者不破前低点,下跌动能也衰减,这种形态称为动能不足,本质上和背离是一样的,都是能量衰减的一种变现,背离所具有的属性和原理适用于动能不足。
单位调整周期内,之间,线段之内,线段之间的隐形背离或隐形动能不足
主级别归零轴启动反弹的内部过程:
1. 第一过程,主级别所包含的小级别在零轴之下首先完成超跌反弹的过程
2. 第二过程,小级别完成底部调整后,才正式启动主级别归零轴反弹
底部形态变盘的四个阶段
确认底部区域需要四个条件
1. 确认引起下跌行情中的主要时间级别
当K线出现下跌行情时,一定是某个时间级别在零轴之上进行的归零轴调整而引起的,受到零轴的引力作用,这个级别的MACD黄白线会被拉回零轴。这个时间级别是:上穿零轴后第一次开始归零轴的时间级别。
2. 确认主级别归零轴后的形态属性
K线价格要触碰到EMA52均线的位置附近,同时MACD的黄白线无限接近零轴。也就是说,K线的价格一旦触碰到EMA52的位置附近,因为这个位置能否形成支撑反弹,主要是看归零轴的这两个条件能否一直保持,并开始归零轴反弹的第一个过程,即主级别所包含的小级别在零轴之下的超跌反弹。
3. 在归零轴的形态满足条件的情况下,确认子级别是否有高位空的形态
当主级别第一次触碰到当前级别EMA52均线的位置附近时,我们要看包含在主级别之下的小级别在零轴的下方是否产生了高位空的形态。只有当这些小级别出现高位空的形态时,才能出现有效的归零轴的超跌反弹,而超跌反弹的变向则为主级别归零轴后出现的止跌反弹的走势。
4. 确认零轴之下的最大子级别运行底部变盘的四个阶段
当主级别进入底部区域后,小级别的超跌反弹将逐级别开始,而时间级别的运行逻辑则是从小级别依次运行。因此,当主级别包含的小级别零轴之下的最后一个子级别完成调整后,主级别的归零轴反弹才正式开启。这些零轴之下的子级别的运行逻辑即为主级别底部变盘的四个阶段。
第一阶段:零轴之下的子级别的单边下跌
第二阶段:零轴之下的子级别的超跌反弹
第三阶段:零轴之下的子级别的归零轴反抽之后产生背离/动能不足
第四阶段:零轴之下的子级别跟零轴形成粘合或者零轴纠缠
底部形态V字反转的条件
当主级别归零轴后,由于小级别的超跌反弹和反抽容易在行情的底部走出横盘震荡的走势,当某个时间级别的超跌反弹导致K线突破这个横盘区间时,我们便把这种走势称为V字反转的走势
V字反转走势发生的条件
1. 主级别保持归零轴形态且MACD出现收敛的状态
2. 零轴之下大多数小级别已经完成线段调整,并形成了底部的横盘结构,剩下未调整的级别没有明显的高位空
3. 下一个长级别的超跌反弹归零轴所触碰到EMA52均线的位置需要有效突破底部横盘震荡的区间
MACD收敛
K线在下跌行情中进入底部区域,当MACD的黄白线由倾斜向下趋于零轴的方向转为拐头形态,同时下跌能量柱由逐渐增长转为衰减时,我们把这种形态称为MACD收敛形态
抢底原理
当行情运行到当前级别底部调整的第三阶段时,即背离/下跌动能不足时,即为我们最佳买入机会。因为一旦启动了V字反转的走势,K线的价格将不容易再出现大的回调机会,而V字反转的行情极容易导致剩下的海味调整的其他小级别直接击穿零轴,或者出现以横代跌的走势而不在出现明显的反抽下跌。这就是V字反转结构形成的条件下的抢底原理。
第一代时间级别当值的有效性满足两个条件
1. 第一代时间级别不能击穿零轴,如果击穿零轴,则本级别当值作用失效
2. 每个当值的第一代时间级别的反弹行情必须推动其长级别在上涨线段中的第一个单位调整周期处于高位的形态
+130
View File
@@ -0,0 +1,130 @@
# Chan — 缠论分析引擎
把 OHLCV K 线拆成缠论结构(K 线单元 → 合并 K 线 → 分型 → 笔 → 线段 → 中枢 → 买卖点),
配一个 TradingView Charting Library 的 Web 界面,外加一套验证信号有效性的回测脚本。
标的不限:加密永续(ccxt)与 A 股(akshare)都走同一条分析链路。
```
chanlun/ 缠论引擎,纯 pandas/numpy,无外部指标库依赖
web/ Flask API + 图表界面
research/ 信号有效性验证脚本(step1 ~ step30
data/ 本地 K 线(freqtrade 的 feather 格式)
```
## 安装
需要 Python ≥ 3.11pandas 3.x / numpy 2.x 的要求,不是本项目代码的限制)。
```bash
python -m venv .venv
.venv/bin/pip install -r requirements.txt # 核心运行时,8 个包
.venv/bin/pip install -r requirements-dev.txt # 另加测试与 research/ 所需
```
## 跑 Web
```bash
cd web
../.venv/bin/python app.py # 默认 http://0.0.0.0:8128
```
从仓库根跑 `.venv/bin/python web/app.py` 也可以——Python 会把脚本所在目录放进 `sys.path`
但**不能用 `python -m web.app`**,也不能 `import web.app``web/` 内部是无前缀导入
`import config``from api.analyze import bp`),`-m` 方式下 `sys.path` 里是仓库根而不是
`web/`,会 `ModuleNotFoundError: No module named 'config'`
配置全部走环境变量,见 `web/config.py`
| 变量 | 默认值 | 用途 |
|------|--------|------|
| `FLASK_HOST` / `FLASK_PORT` | `0.0.0.0` / `8128` | 监听地址 |
| `DATA_SERVICE_URL` | `https://provider.jackyu66.com` | 行情 REST 源 |
| `DATA_SERVICE_WS_URL` | `wss://jackyu66.com/ws` | 行情 WebSocket 源 |
| `ASHARE_DP_URL` | `http://103.179.242.166:8000` | A 股数据源 |
| `CHAN_HTTP_PROXY` | 未设置则不走代理 | ccxt / HTTP 代理 |
| `MACD_FACTOR` / `MACD_SMOOTH` | `1` / `1` | MACD 周期倍数,默认 12/26/9 |
主要接口:`GET /api/analyze` 返回某标的某周期的完整缠论结构,`/api/klines/recent`
取最新 K 线,`/api/trend_filter``/api/trend_detail` 做多周期趋势筛选,
`/api/symbols``/api/search_stock``/api/sectors` 等负责标的检索。页面在 `/``/chan_tv`
## 作为库使用
```python
import pandas as pd
from chanlun import TF_DF
# 需要 date/open/high/low/close/volume 六列,date 为 datetime
df = pd.read_feather("data/binance/futures/BTC_USDT_USDT-1h-futures.feather")
tf = TF_DF(df, interval=1, timeframe="1h")
len(tf.klu_list) # K 线单元
len(tf.klc_list) # 合并 K 线(处理包含关系后)
len(tf.bi_list) # 笔
len(tf.seg_list) # 线段
len(tf.zs_list) # 中枢
tf.bi_list[-1].dir # Chan_BI_DIR.DOWN
tf.chanmacd # MACD 结构分析(背驰判定用)
```
**`interval` 的单位是分钟**,对传入的 df 做重采样;`interval=1` 是特例,表示原样使用、
不重采样。所以拿 1h 的 feather 要传 `interval=1`,拿 1m 数据想看 1h 才传 `interval=60`
```python
df1m = pd.read_feather("data/binance/futures/BTC_USDT_USDT-1m-futures.feather")
TF_DF(df1m, interval=5, timeframe="5m")
TF_DF(df1m, interval=60, timeframe="1h")
```
传错不会报错,只会静默给出错误周期的结构——1h 数据配 `interval=4` 相当于按 4 分钟
重采样,结果与 `interval=1` 完全相同。
## 分析流程
`TF_DF.init_TF_DF()` 按顺序做这几步,每步的实现在 `chanlun/pipeline/builders/` 下同名文件:
1. `resample_to_interval` — 重采样(`interval != 1` 时)
2. `add_indicators` — 追加 33 列指标(MACD / BBANDS / EMA / RSI / ATR 等)
3. `cal_kl_data``klu_list` — K 线单元
4. `get_klc_list``klc_list` — 按包含关系合并 K 线,并标记分型
5. `cal_bi_list``bi_list` — 笔
6. `cal_bi_zs_list_pure``bi_zs_list` — 笔中枢
7. `get_seg_list``seg_list` — 线段
8. `get_zs_list` / `get_big_zs_list` — 中枢与大级别中枢
9. `ChanMACD(klu_list)` — MACD 段 / 柱堆结构,供背驰判定
## 数据
`data/<交易所>/futures/<SYMBOL>-<周期>-futures.feather`,即 freqtrade 的下载格式,
`data/binance/futures/BTC_USDT_USDT-1h-futures.feather`
`research/lib/data.py` 负责定位:`BTC/USDT:USDT` + `1h` 会解析到上面这个路径,
找不到本地文件则回落到远端拉取。
## 测试
```bash
.venv/bin/python -m pytest chanlun/tests web/tests -q
```
`chanlun/tests/test_ta_compat.py` 有个**需要注意的陷阱**:它把 `chanlun/indicators/ta.py`
的输出逐 bar 钉在 TA-Lib 上,但 **TA-Lib 不存在时会静默跳过**。也就是说改了 `ta.py`
之后在没装 TA-Lib 的环境里跑,测试会显示通过,其实一项都没验证。改动那个文件时请先装:
```bash
sudo apt-get install -y libta-lib0 ta-lib-dev
.venv/bin/pip install TA-Lib technical
```
## 已知问题
- **`web/DEPLOY_GUIDE.md` 已失效**:它引用的 `deploy_venv.sh``stop_venv.sh`
`status_venv.sh` 等 6 个脚本都在 `7f393b9` 精简提交里删掉了,目前没有部署脚本。
两个 systemd unit 文件(`web/chanlun-web*.service`)仍可参考,但它们用 gunicorn
且写死端口 8123,与 `config.py` 默认的 8128 不一致,gunicorn 也不在依赖清单里。
- **`web/README.txt` 已过时**:它说的 `web/requirements.txt` 不存在,依赖清单在仓库根目录。
- **四个零引用的死文件**`chanlun/analysis/` 下的 `ChanPY.py``ChanLun_Classifier.py`
`Find_Trend.py``ChanHeng.py` 全项目无人引用。`ChanPY.py` 依赖未安装的外部 chan.py 库,
另外三个需要 matplotlib / mplfinance / xgboost / scikit-learn——这些都**不在**依赖清单里,
是有意为之。要用得自行安装。
-2583
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
# bsp_monitor 复用 Hermes Agent 的 Telegram bot
# notify.py 从 ~/.hermes/.env 直接读取 TELEGRAM_BOT_TOKEN
# 此处无需重复配置
-1
View File
@@ -1 +0,0 @@
# bsp_monitor - 缠论买卖点监控 (BTC/USDT 1m)
-174
View File
@@ -1,174 +0,0 @@
"""
engine.py - 缠论管线封装DataFrame KLU KLC BI SEG ZS BSP
复用 ~/Project/Chan/ 下的 TF_DF 模块管线步骤对齐 TF_DF.get_bsp_state()
"""
import sys
import os
from typing import List, Optional
_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PARENT not in sys.path:
sys.path.insert(0, _PARENT)
import pandas as pd
from ChanEnum import (
Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_BI_DIR,
Chan_ZS_DIR,
)
from ChanBSP import ChanBSP
from ChanBI import ChanBI
# 仅导入类,不触发 TF_DF.__init__
from TF_DF import TF_DF as _TF_DF_Class
class ChanEngine:
"""缠论管线,对齐 TF_DF.get_bsp_state() 的调用顺序。"""
def __init__(self, df: pd.DataFrame):
if df.empty or len(df) < 50:
raise ValueError("DataFrame 至少需要 50 根 K 线")
if "date" not in df.columns and "timestamp" in df.columns:
df["date"] = df["timestamp"]
self.df = df
self._tf = _TF_DF_Class.__new__(_TF_DF_Class) # 不调用 __init__
# Step 0: 添加 TA 指标 (MACD/EMA/BB/RSI)
self._df_with_indicators = self._tf.add_indicators(df.copy())
# Step 1: KLU — get_klu_list → get_kl_data → cal_kl_data
self.klu_list = self._tf.get_klu_list(self._df_with_indicators)
# Step 2: KLC — 内部已含 ChanMACD.cal_macd_state() + cal_trend()
self.klc_list = self._tf.get_klc_list(self.klu_list)
# Step 3: BI (stroke)
self.bi_list = self._tf.cal_bi_list(self.klc_list)
# Step 4: SEG (segment)
self.seg_list = self._tf.get_seg_list(self.bi_list)
# Step 5: ZS — cal_bi_zs(seg_list) 对齐 get_bsp_state(从线段计算笔中枢)
self.bi_zs_list: List = self._tf.cal_bi_zs(self.seg_list)
# Step 6: BSP (buy/sell points)
self.bsp_list: List[ChanBSP] = self._tf.find_all_bsp(
self.bi_list, self.bi_zs_list
)
def get_second_last_bi(self) -> Optional[ChanBI]:
"""获取倒数第二笔(最新确认的笔)。"""
confirmed = [b for b in self.bi_list if b.is_sure]
if len(confirmed) >= 2:
return confirmed[-2]
elif len(confirmed) == 1:
return confirmed[-1]
return None
def get_bsp_for_bi(self, bi: ChanBI) -> Optional[ChanBSP]:
"""检查某个 Bi 的 end_klc 是否是买卖点。"""
if bi is None or not bi.is_sure:
return None
klc = bi.end_klc
if klc is None:
return None
if klc.bsp and klc.bsp_type != Chan_BSP_TYPE.NONE:
for bsp in self.bsp_list:
if bsp.klc is klc:
return bsp
return None
# ── 格式化 ──
@staticmethod
def _bsp_type_name(t: Chan_BSP_TYPE) -> str:
import ChanEnum
names = {
Chan_BSP_TYPE.B1: "一类买点(B1)",
Chan_BSP_TYPE.B2: "二类买点(B2)",
Chan_BSP_TYPE.B3: "三类买点(B3)",
Chan_BSP_TYPE.S1: "一类卖点(S1)",
Chan_BSP_TYPE.S2: "二类卖点(S2)",
Chan_BSP_TYPE.S3: "三类卖点(S3)",
}
return names.get(t, str(t))
@staticmethod
def _bi_dir_name(d) -> str:
return "⬆️ 向上" if d == Chan_BI_DIR.UP else "⬇️ 向下"
@staticmethod
def _fx_strength_name(klc_fx_type) -> str:
import ChanEnum
names = {
Chan_KLC_FX.TOP0: "TOP0(弱)", Chan_KLC_FX.TOP1: "TOP1(标准)",
Chan_KLC_FX.TOP2: "TOP2(强)", Chan_KLC_FX.TOP3: "TOP3(二类)",
Chan_KLC_FX.TOP4: "TOP4(BB上轨)", Chan_KLC_FX.TOP5: "TOP5",
Chan_KLC_FX.TOP6: "TOP6(高位空)", Chan_KLC_FX.TOP7: "TOP7(背驰)",
Chan_KLC_FX.TOP8: "TOP8(信号线)",
Chan_KLC_FX.BOTTOM0: "BOTTOM0(弱)", Chan_KLC_FX.BOTTOM1: "BOTTOM1(标准)",
Chan_KLC_FX.BOTTOM2: "BOTTOM2(强)", Chan_KLC_FX.BOTTOM3: "BOTTOM3(二类)",
Chan_KLC_FX.BOTTOM4: "BOTTOM4(BB下轨)", Chan_KLC_FX.BOTTOM5: "BOTTOM5(零轴下)",
Chan_KLC_FX.BOTTOM6: "BOTTOM6(高位空)", Chan_KLC_FX.BOTTOM7: "BOTTOM7(背驰)",
Chan_KLC_FX.BOTTOM8: "BOTTOM8(信号线)",
}
return names.get(klc_fx_type, f"UNKNOWN({klc_fx_type})")
@staticmethod
def _utc_to_cst(time_str: str) -> str:
"""UTC 时间字符串 → 东八区 (UTC+8)。"""
from datetime import datetime, timedelta, timezone
dt = datetime.fromisoformat(str(time_str))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
cst = dt.astimezone(timezone(timedelta(hours=8)))
return cst.strftime("%Y-%m-%d %H:%M:%S CST")
def format_bsp_detail(self, bsp: ChanBSP, symbol: str = "BTC/USDT:USDT", tf: str = "1m") -> str:
bi = bsp.bi
klc = bsp.klc
bsp_type = bsp.type
bsp_dir = bsp.dir
emoji = "🟢" if bsp_dir == Chan_BSP_DIR.BUY else "🔴"
dir_label = "买点" if bsp_dir == Chan_BSP_DIR.BUY else "卖点"
symbol_short = symbol.split(":")[0].replace("/", "")
lines = [
f"{emoji} [{dir_label}] {self._bsp_type_name(bsp_type)} — <b>{symbol_short} {tf}</b>",
"",
f"⏰ 确认: <code>{self._utc_to_cst(klc.end_time)}</code>",
f"💰 价格: <b>{klc.close:.2f}</b>",
f"📐 笔方向: {self._bi_dir_name(bi.dir)}",
f"📏 笔高度: ${bi.height:.2f} 宽度: {bi.width}K 斜率: {bi.slop:.2f}",
f"🔩 分型强度: {self._fx_strength_name(klc.klc_fx_type)}",
]
if bsp.zs:
zs = bsp.zs
zs_dir = "UP" if hasattr(zs, 'dir') and hasattr(Chan_ZS_DIR, 'UP') and zs.dir == Chan_ZS_DIR.UP else "DOWN"
lines.append(f"🏠 中枢: {zs.zd:.2f} {zs.zg:.2f} ({zs_dir}, #{getattr(zs, 'index', 0) + 1})")
if bsp.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1):
lines.append("📊 MACD背驰: 有 (离开段能量 < 进入段)")
if hasattr(klc, 'ema_status') and klc.ema_status:
ema52 = klc.ema_status.get('ema52', {})
if ema52:
pos = str(ema52.get('pos', '?'))
lines.append(f"📈 EMA52: {pos} (值: {klc.ema52:.2f})")
lines.append(f"📋 KLC状态: {klc.klc_state}")
if bi.pre:
prev = bi.pre
lines.extend([
"────",
f"⬅️ 前一笔: {self._bi_dir_name(prev.dir)} "
f"高度: ${prev.height:.2f} 宽度: {prev.width}K",
])
return "\n".join(lines)
-62
View File
@@ -1,62 +0,0 @@
"""
fetcher.py - data_provider HTTP API 拉取 K 线数据
"""
from typing import List, Optional
import requests
import pandas as pd
import logging
logger = logging.getLogger(__name__)
PROVIDER_URL = "http://103.179.242.166"
PROVIDER_URL = "http://127.0.0.1"
FETCH_LIMIT = 1000
_symbols_cache: Optional[List[str]] = None
# 只推送 BTC,其他币对暂不监控
_SYMBOL_WHITELIST = {"BTC/USDT:USDT", "ETH/USDT:USDT", "SOL/USDT:USDT"}
def get_symbols() -> list[str]:
"""获取要监控的币对列表(目前只监控 BTC)。"""
global _symbols_cache
if _symbols_cache is not None:
return _symbols_cache
try:
resp = requests.get(f"{PROVIDER_URL}/health", timeout=10)
resp.raise_for_status()
all_symbols = resp.json().get("symbols", [])
_symbols_cache = [s for s in all_symbols if s in _SYMBOL_WHITELIST]
logger.info(f"获取到 {len(all_symbols)} 个币对,过滤后监控 {len(_symbols_cache)} 个: {_symbols_cache}")
except Exception as e:
logger.error(f"获取币对列表失败: {e}")
_symbols_cache = ["BTC/USDT:USDT"]
return _symbols_cache
def fetch_ohlcv(symbol: str, tf: str = "1m") -> pd.DataFrame:
"""从 data_provider API 拉取某个币对最近 FETCH_LIMIT 根 K 线。"""
url = f"{PROVIDER_URL}/api/candles"
params = {
"symbol": symbol,
"tf": tf,
"limit": FETCH_LIMIT,
}
resp = requests.get(url, params=params, timeout=30)
resp.raise_for_status()
data = resp.json()
if not data:
logger.warning(f"{symbol}: API 返回空数据")
return pd.DataFrame()
df = pd.DataFrame(data)
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df["date"] = df["timestamp"]
df = df.drop_duplicates(subset="timestamp").sort_values("timestamp").reset_index(drop=True)
return df
-220
View File
@@ -1,220 +0,0 @@
#!/usr/bin/env python3
"""
main.py - 缠论多周期买卖点监控
每整分钟
1. data_provider 拉取所有币对多周期 K 线
2. 每个币对 × 每个周期独立跑缠论管线
3. 检测新笔确认 BSP 推送
"""
import asyncio
import logging
import sys
import os
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from typing import Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fetcher import fetch_ohlcv, get_symbols
from engine import ChanEngine
from notify import send_bsp_alert, BOT_TOKEN, CHAT_ID
_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PARENT not in sys.path:
sys.path.insert(0, _PARENT)
from ChanEnum import Chan_BI_DIR
# from ChanPivotMonitor import ChanPivotMonitor # 暂停中枢监控
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("bsp_monitor")
TIMEFRAMES = ["1m", "5m", "15m", "1h"]
def _short(symbol: str) -> str:
"""BTC/USDT:USDT → BTCUSDT"""
return symbol.split(":")[0].replace("/", "")
def _bi_id(bi) -> Optional[tuple]:
"""笔的稳定标识,基于首K线时间戳。"""
if bi.start_klc is None:
return None
return (bi.start_klc.start_time,)
def _push_bsp(engine: ChanEngine, bsp, symbol: str, tf: str) -> bool:
"""推送 BSP 到 Telegram(带去重)。"""
if bsp.klc is None:
return False
key = f"{symbol}_{bsp.type}_{bsp.klc.end_time}_{tf}"
msg = engine.format_bsp_detail(bsp, symbol, tf)
msg = _escape_html(msg)
if send_bsp_alert(msg, bsp_key=key):
logger.info(f"[{_short(symbol)} {tf}] ✅ BSP: {key}")
return True
return False
@dataclass
class TfState:
"""单个周期的状态。"""
last_bi_id: Optional[tuple] = None
last_df_ts: object = None
first_run: bool = True
# pivot_monitor: ChanPivotMonitor = None # 暂停中枢监控
# def __post_init__(self):
# if self.pivot_monitor is None:
# self.pivot_monitor = ChanPivotMonitor()
@dataclass
class SymbolState:
symbol: str
tfs: dict = field(default_factory=dict)
def __post_init__(self):
self.tfs = {tf: TfState() for tf in TIMEFRAMES}
class BSPMonitor:
def __init__(self):
symbols = get_symbols()
self._states: dict[str, SymbolState] = {
s: SymbolState(symbol=s) for s in symbols
}
logger.info(f"监控 {len(symbols)}×{len(TIMEFRAMES)} 币对×周期: "
f"{', '.join(_short(s) for s in symbols)}")
async def tick(self):
tick_start = time.monotonic()
logger.info("── tick 开始 ──")
for symbol, st in self._states.items():
await self._tick_symbol(symbol, st)
elapsed = (time.monotonic() - tick_start) * 1000
logger.info(f"── tick 结束 ({elapsed:.0f}ms) ──")
async def _tick_symbol(self, symbol: str, st: SymbolState):
name = _short(symbol)
for tf in TIMEFRAMES:
await self._check_tf(symbol, tf, st.tfs[tf], name)
async def _check_tf(self, symbol: str, tf: str, ts: TfState, name: str):
# 1. 拉取 K 线
try:
df = fetch_ohlcv(symbol, tf)
except Exception as e:
logger.error(f"[{name} {tf}] 拉取失败: {e}")
return
if df.empty:
return
# 2. 检查是否有新 K 线
latest_ts = df.iloc[-1]["timestamp"]
if ts.last_df_ts and latest_ts <= ts.last_df_ts:
return
ts.last_df_ts = latest_ts
# 3. 运行缠论管线
try:
engine = ChanEngine(df)
except Exception as e:
logger.error(f"[{name} {tf}] 缠论计算失败: {e}", exc_info=True)
return
# 4. 中枢特征更新(暂停)
# try:
# ts.pivot_monitor.update(engine.bi_zs_list)
# except Exception as e:
# logger.debug(f"[{name} {tf}] 中枢特征更新失败: {e}")
# 5. BSP 检测
confirmed = [b for b in engine.bi_list if b.is_sure]
if len(confirmed) < 2:
return
last_confirmed = confirmed[-1]
current_bi_id = _bi_id(last_confirmed)
if current_bi_id is None:
return
if ts.first_run:
ts.first_run = False
ts.last_bi_id = current_bi_id
bsp = engine.get_bsp_for_bi(last_confirmed)
if bsp:
_push_bsp(engine, bsp, symbol, tf)
logger.info(
f"[{name} {tf}] 首次完成 — "
f"{len(confirmed)} 笔, {len(engine.bsp_list)} BSP"
)
return
if current_bi_id == ts.last_bi_id:
return
ts.last_bi_id = current_bi_id
bi_dir = "⬆️" if last_confirmed.dir == Chan_BI_DIR.UP else "⬇️"
logger.info(f"[{name} {tf}] 新笔确认 — #{len(confirmed)} "
f"{bi_dir} 高度: ${last_confirmed.height:.2f}")
bsp = engine.get_bsp_for_bi(last_confirmed)
if bsp:
_push_bsp(engine, bsp, symbol, tf)
async def run(self):
logger.info("=" * 50)
logger.info(f"bsp_monitor 启动 — {len(self._states)} 币对 "
f"× {len(TIMEFRAMES)} 周期 ({', '.join(TIMEFRAMES)})")
logger.info(f"Telegram: {'已配置' if BOT_TOKEN and CHAT_ID else '⚠️ 未配置'}")
logger.info("=" * 50)
logger.info("首次运行(初始化)...")
await self.tick()
while True:
now = datetime.now(timezone.utc)
next_minute = now.replace(second=0, microsecond=0) + timedelta(minutes=1)
wait_seconds = max(0.1, (next_minute - now).total_seconds())
logger.info(f"等待 {wait_seconds:.0f}s 到 {next_minute.strftime('%H:%M:%S')}UTC")
await asyncio.sleep(wait_seconds)
try:
await self.tick()
except Exception as e:
logger.error(f"tick 异常: {e}", exc_info=True)
await asyncio.sleep(5)
def _escape_html(msg: str) -> str:
"""HTML 转义,保留已有的 <b>/<code> 标签。"""
msg = msg.replace("&", "&amp;")
msg = msg.replace("<b>", "\x00B\x00").replace("</b>", "\x00/B\x00")
msg = msg.replace("<code>", "\x00C\x00").replace("</code>", "\x00/C\x00")
msg = msg.replace("<", "&lt;").replace(">", "&gt;")
msg = msg.replace("\x00B\x00", "<b>").replace("\x00/B\x00", "</b>")
msg = msg.replace("\x00C\x00", "<code>").replace("\x00/C\x00", "</code>")
return msg
if __name__ == "__main__":
monitor = BSPMonitor()
try:
asyncio.run(monitor.run())
except KeyboardInterrupt:
logger.info("收到中断信号,退出")
-61
View File
@@ -1,61 +0,0 @@
"""
notify.py - Telegram 推送
"""
import logging
import requests
logger = logging.getLogger(__name__)
BOT_TOKEN = "8742822093:AAGzD1vS7ru7ROhgcOjA-UyHb4R8Cfcqv3Q"
CHAT_ID = "580807463"
def send_telegram_message(text: str) -> bool:
"""发送 Telegram 消息(不去重,每次调用都发)。"""
if not BOT_TOKEN or not CHAT_ID:
logger.warning("Telegram 未配置,跳过推送")
return False
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
try:
resp = requests.post(
url,
json={
"chat_id": CHAT_ID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
},
timeout=10,
)
resp.raise_for_status()
return True
except Exception as e:
logger.error(f"Telegram 推送失败: {e}")
return False
def send_bsp_alert(text: str, bsp_key: str = "") -> bool:
"""推送 BSP 消息。"""
if not BOT_TOKEN or not CHAT_ID:
logger.warning("Telegram 未配置,跳过推送")
return False
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
try:
resp = requests.post(
url,
json={
"chat_id": CHAT_ID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
},
timeout=10,
)
resp.raise_for_status()
logger.info(f"Telegram 推送成功: {bsp_key or 'no-key'}")
return True
except Exception as e:
logger.error(f"Telegram 推送失败: {e}")
return False
-11
View File
@@ -1,11 +0,0 @@
#!/bin/bash
# bsp_monitor 启动脚本
# 用法: bash run.sh
cd "$(dirname "$0")"
echo "=== bsp_monitor ==="
echo "启动时间: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "监控: BTC/USDT:USDT 1m 缠论买卖点"
echo "推送: Telegram (复用 Hermes bot)"
echo "==================="
exec /usr/bin/python3 -u main.py
-380
View File
@@ -1,380 +0,0 @@
#!/usr/bin/env python3
"""
twitter_web.py Twitter 监控账号管理 Web 界面
单文件零依赖只用到 Python 标准库
"""
import json
import os
import sys
import re
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
WATCHLIST_PATH = os.path.join(SCRIPT_DIR, "twitter_watchlist.json")
STATE_PATH = os.path.join(SCRIPT_DIR, "twitter_state.json")
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8010
def extract_username(value: str) -> str:
value = value.strip().rstrip("/")
if value.startswith("@"):
return value[1:]
for pattern in [r"(?:twitter\.com|x\.com)/(\w+)(?:/|$)", r"/(\w+)$"]:
m = re.search(pattern, value)
if m:
return m.group(1)
if re.match(r"^\w+$", value):
return value
raise ValueError(f"无法提取用户名: {value}")
def load_json(path):
if os.path.exists(path):
with open(path) as f:
return json.load(f)
return {}
def save_json(path, data):
with open(path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def get_watchlist():
return load_json(WATCHLIST_PATH).get("users", [])
def save_watchlist(users):
save_json(WATCHLIST_PATH, {"users": users})
def get_state():
return load_json(STATE_PATH)
HTML = """<!DOCTYPE html>
<html lang="zh">
<head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LVVXH3TL04"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-LVVXH3TL04');
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Twitter 监控管理</title>
<style>
:root {
--bg: #0d1117; --card: #161b22; --border: #30363d;
--text: #c9d1d9; --muted: #8b949e; --accent: #58a6ff;
--green: #3fb950; --red: #f85149; --yellow: #d2991d;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:var(--bg); color:var(--text); padding:24px; max-width:680px; margin:auto; }
h1 { font-size:20px; margin-bottom:4px; }
.sub { color:var(--muted); font-size:12px; margin-bottom:20px; }
.add-bar { display:flex; gap:8px; margin-bottom:20px; }
.add-bar input { flex:1; padding:8px 12px; border:1px solid var(--border);
border-radius:6px; background:var(--card); color:var(--text); font-size:14px; outline:none; }
.add-bar input:focus { border-color:var(--accent); }
.add-bar input::placeholder { color:var(--muted); }
button { padding:8px 16px; border:none; border-radius:6px; cursor:pointer; font-size:13px;
font-weight:500; transition:opacity .15s; }
button:hover { opacity:0.85; }
.btn-add { background:var(--accent); color:#fff; }
.btn-edit, .btn-save { background:var(--yellow); color:#000; }
.btn-del { background:var(--red); color:#fff; }
.btn-cancel { background:var(--border); color:var(--text); }
.account { background:var(--card); border:1px solid var(--border); border-radius:8px;
padding:12px 16px; margin-bottom:8px; display:flex; align-items:center; gap:12px; }
.account .name { font-weight:600; min-width:160px; }
.account .name a { color:var(--accent); text-decoration:none; }
.account .name a:hover { text-decoration:underline; }
.account .meta { font-size:12px; color:var(--muted); flex:1; }
.account .actions { display:flex; gap:6px; flex-shrink:0; }
.edit-row { display:flex; gap:6px; align-items:center; width:100%; }
.edit-row input { flex:1; padding:6px 10px; border:1px solid var(--accent);
border-radius:4px; background:var(--bg); color:var(--text); font-size:13px; outline:none; }
.badge { display:inline-block; font-size:11px; padding:2px 8px; border-radius:10px;
background:var(--green); color:#000; margin-left:6px; }
.empty { text-align:center; padding:60px 20px; color:var(--muted); }
.empty p { margin-bottom:8px; }
.toast { position:fixed; bottom:20px; right:20px; padding:10px 20px; border-radius:6px;
font-size:13px; color:#fff; opacity:0; transition:opacity .3s; z-index:100; }
.toast.show { opacity:1; }
.toast.ok { background:var(--green); }
.toast.err { background:var(--red); }
</style>
</head>
<body>
<h1>🐦 Twitter 账号监控</h1>
<p class="sub">管理 twitterapi.io 监控账号 · 增删改查</p>
<div class="add-bar">
<input id="urlInput" type="text" placeholder="输入 Twitter/X 链接或用户名..." autofocus>
<button class="btn-add" onclick="addAccount()"> 添加</button>
</div>
<div id="list"></div>
<div class="toast" id="toast"></div>
<script>
const API = '/twitter/api/accounts';
let editing = null;
async function api(method, path='', body=null) {
const opts = { method, headers:{} };
if (body) { opts.headers['Content-Type']='application/json'; opts.body=JSON.stringify(body); }
const r = await fetch(API + path, opts);
const data = await r.json();
if (!r.ok) throw new Error(data.error || '请求失败');
return data;
}
function toast(msg, ok=true) {
const t = document.getElementById('toast');
t.textContent = msg; t.className = 'toast ' + (ok?'ok':'err') + ' show';
setTimeout(() => t.classList.remove('show'), 2500);
}
async function load() {
const data = await api('GET');
const div = document.getElementById('list');
if (!data.accounts.length) {
div.innerHTML = '<div class="empty"><p>📭 暂无监控账号</p><p style="font-size:12px;color:var(--muted)">在上方输入 Twitter/X 链接或用户名添加</p></div>';
return;
}
div.innerHTML = data.accounts.map(a => `
<div class="account" id="row-${a.username}">
${editing===a.username ? `
<div class="edit-row">
<input id="editInput" value="${esc(a.display_name || a.username)}" placeholder="备注名称">
<button class="btn-save" onclick="saveEdit('${esc(a.username)}')">保存</button>
<button class="btn-cancel" onclick="cancelEdit()">取消</button>
</div>
` : `
<div class="name">
<a href="https://x.com/${esc(a.username)}" target="_blank">@${esc(a.username)}</a>
${a.display_name && a.display_name !== a.username ? `<span style="color:var(--text)">(${esc(a.display_name)})</span>` : ''}
</div>
<div class="meta">
添加: ${a.added_at?.slice(0,10) || '?'}
${a.last_check ? ` · 上次检查: ${a.last_check}` : ''}
</div>
<div class="actions">
<button class="btn-edit" onclick="startEdit('${esc(a.username)}','${esc(a.display_name||a.username)}')"></button>
<button class="btn-del" onclick="removeAccount('${esc(a.username)}')">🗑</button>
</div>
`}
</div>
`).join('');
}
function esc(s) { return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/'/g,'&#39;'); }
async function addAccount() {
const inp = document.getElementById('urlInput');
const val = inp.value.trim();
if (!val) { toast('请输入链接或用户名', false); return; }
try {
const r = await api('POST', '', {url: val});
toast(r.message || '添加成功');
inp.value = '';
load();
} catch(e) { toast(e.message, false); }
}
async function removeAccount(username) {
if (!confirm(`确定删除 @${username}`)) return;
try {
const r = await api('DELETE', '/' + username);
toast(r.message || '已删除');
load();
} catch(e) { toast(e.message, false); }
}
function startEdit(username, name) {
editing = username;
load();
setTimeout(() => {
const inp = document.getElementById('editInput');
if (inp) { inp.focus(); inp.select(); }
}, 50);
}
function cancelEdit() { editing = null; load(); }
async function saveEdit(username) {
const val = document.getElementById('editInput').value.trim();
editing = null;
try {
const r = await api('PUT', '/' + username, {display_name: val});
toast(r.message || '已更新');
load();
} catch(e) { toast(e.message, false); }
}
document.getElementById('urlInput').addEventListener('keydown', e => {
if (e.key === 'Enter') addAccount();
});
load();
</script>
</body>
</html>"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass # silent
def _send(self, code, body, content_type="application/json"):
body = body.encode() if isinstance(body, str) else json.dumps(body, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", content_type + "; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _json(self, code, data):
self._send(code, data)
def _error(self, code, msg):
self._json(code, {"error": msg})
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
path = urlparse(self.path).path
if path == "/" or path == "/index.html":
self._send(200, HTML, "text/html")
return
if path.startswith("/api/accounts"):
username = path[len("/api/accounts"):].strip("/")
if username:
# GET /api/accounts/<username> — single account
users = get_watchlist()
state = get_state()
for u in users:
if u["username"].lower() == username.lower():
entry = dict(u)
entry["last_check"] = state.get(u["username"], {}).get("last_check")
self._json(200, entry)
return
self._error(404, "账号不存在")
return
# GET /api/accounts — list all
users = get_watchlist()
state = get_state()
accounts = []
for u in users:
entry = dict(u)
sc = state.get(u["username"], {})
ts = sc.get("last_check")
if ts:
try:
ts = datetime.fromisoformat(ts).strftime("%m-%d %H:%M")
except Exception:
pass
else:
ts = "从未"
entry["last_check"] = ts
accounts.append(entry)
self._json(200, {"accounts": accounts})
else:
self._error(404, "Not Found")
def do_POST(self):
path = urlparse(self.path).path
if path != "/api/accounts":
self._error(404, "Not Found")
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
url = body.get("url", "").strip()
if not url:
self._error(400, "缺少 url 参数")
return
try:
username = extract_username(url)
except ValueError:
self._error(400, "无法从输入中提取用户名,请输入 Twitter/X 链接或 @用户名")
return
users = get_watchlist()
if any(u["username"].lower() == username.lower() for u in users):
self._error(409, f"@{username} 已在监控列表中")
return
display_name = body.get("display_name", "").strip() or username
users.append({
"username": username,
"display_name": display_name,
"added_at": datetime.now(timezone.utc).isoformat(),
})
save_watchlist(users)
self._json(201, {"message": f"✅ 已添加 @{username}", "username": username})
def do_PUT(self):
path = urlparse(self.path).path
username = path[len("/api/accounts"):].strip("/")
if not username:
self._error(400, "缺少用户名")
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
display_name = body.get("display_name", "").strip()
users = get_watchlist()
for u in users:
if u["username"].lower() == username.lower():
if display_name:
u["display_name"] = display_name
save_watchlist(users)
self._json(200, {"message": f"✅ @{username} 已更新"})
return
self._error(404, "账号不存在")
def do_DELETE(self):
path = urlparse(self.path).path
username = path[len("/api/accounts"):].strip("/")
if not username:
self._error(400, "缺少用户名")
return
users = get_watchlist()
before = len(users)
users = [u for u in users if u["username"].lower() != username.lower()]
if len(users) < before:
save_watchlist(users)
self._json(200, {"message": f"🗑 已移除 @{username}"})
else:
self._error(404, "账号不存在")
def main():
print(f"🐦 Twitter 监控管理: http://0.0.0.0:{PORT}")
server = HTTPServer(("0.0.0.0", PORT), Handler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n已停止")
server.server_close()
if __name__ == "__main__":
main()
-13
View File
@@ -1,13 +0,0 @@
1. **第一类买卖点**
- 定义:趋势反转的起始点,即在下跌趋势结束时形成的买点(第一类买点),或在上涨趋势结束时形成的卖点(第一类卖点)。这是市场多空力量发生根本性转变的位置。
- 与MACD背驰的关系:Macd背驰是指出中枢后形成的Macd的红绿柱面积比进入中枢时的面积绝对值小,背驰比较的黄白线和柱子面积都在0轴的一个方向上。第一类买点都是在0轴之下背驰形成的,第一类卖点都是在0轴之上的背驰形成的。
2. **第二类买卖点**
- 定义:趋势确认后的回调点。在第一类买卖点之后,价格会回调或反弹,形成第二类买点(回调不破前低)或第二类卖点(反弹不破前高),是对第一类买卖点的确认。第二类买点都是第一次上0轴后回抽确认形成的。第二类卖点都是第一次0轴之下上涨确认形成的。第二类买卖点只会在趋势确认后,第一类买卖点出现之后出现一次,不会重复出现,除非趋势反转之后。
3. **第三类买卖点**
- 定义:趋势延续的确认点。价格突破回调或反弹的中枢区间后,回踩不破关键位置(如中枢上沿或下沿),形成第三类买点(上升趋势延续)或第三类卖点(下降趋势延续)。第三类买卖点只会在中枢确认之后出现。
我们交易的是币安的比特币合约, 数据格式是json, 数据包括现有的持仓, 仓位历史, 账户余额, 你用缠论分析之后, 给出以下分析, 最近的一个中枢在哪里,现在的趋势是什么,现在是否是买卖点,如果是,是那一类买卖点,应该进行何种操作。
+11
View File
@@ -0,0 +1,11 @@
"""缠论引擎正式包。
推荐::
from chanlun import ChanLun, TF_DF
from chanlun.core.ChanEnum import Chan_BI_DIR
"""
from chanlun.pipeline.orchestrator import ChanLun
from chanlun.pipeline.timeframe import TF_DF
__all__ = ["ChanLun", "TF_DF"]
@@ -6,22 +6,20 @@ sys.path.append(os.path.abspath("/Users/jack/Project/freqtrade/user_data/Chan"))
import numpy as np
from datetime import timedelta
from pandas import DataFrame
from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX
from ChanKLU import ChanKLU
from ChanKLC import ChanKLC
from ChanBI import ChanBI
from ChanSBI import ChanSBI
from ChanSEG import ChanSEG
from ChanZS import ChanZS
from ChanBSP import ChanBSP
import talib.abstract as ta
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX
from chanlun.core.ChanKLU import ChanKLU
from chanlun.core.ChanKLC import ChanKLC
from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanSBI import ChanSBI
from chanlun.core.ChanSEG import ChanSEG
from chanlun.core.ChanZS import ChanZS
from chanlun.core.ChanBSP import ChanBSP
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.dates import DateFormatter, date2num
import matplotlib.patches as patches
from technical.util import resample_to_interval
from decimal import Decimal
from ChanLun import ChanLun
from chanlun.pipeline.orchestrator import ChanLun
import xgboost as xgb
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, classification_report
@@ -9,7 +9,7 @@ Feature 描述中枢内部结构,Label 记录中枢后实际演化。
import math
import json
from typing import Optional
from ChanEnum import Chan_BI_DIR
from chanlun.core.ChanEnum import Chan_BI_DIR
class ChanPivotClassifier:
@@ -9,7 +9,7 @@ shift / contraction / duration。
from collections import deque
from typing import Optional
from ChanPivotClassifier import ChanPivotClassifier
from chanlun.analysis.ChanPivotClassifier import ChanPivotClassifier
class ChanPivotMonitor:
@@ -2,7 +2,7 @@ import ccxt
import pandas as pd
import numpy as np
import mplfinance as mpf
from talib import MACD, SMA
from chanlun.indicators import ta
from datetime import datetime, timedelta
import logging
import datetime as dt
@@ -249,8 +249,9 @@ def analyze_higher_timeframe(df_30m):
# 8. Back-divergence detection (enhanced)
def detect_back_divergence(df, strokes, higher_trend):
try:
macd, signal, hist = MACD(df['Close'], fastperiod=12, slowperiod=26, signalperiod=9)
sma20 = SMA(df['Close'], timeperiod=20)
macd_df = ta.MACD(df['Close'], fastperiod=12, slowperiod=26, signalperiod=9)
macd, hist = macd_df['macd'], macd_df['macdhist']
sma20 = ta.SMA(df['Close'], timeperiod=20)
df['macd'] = macd
df['hist'] = hist
df['sma20'] = sma20
+384
View File
@@ -0,0 +1,384 @@
"""快速三类买卖点(引擎内称第四类,B4/S4)。
原本长在 `research/lib/fast_bsp3.py`现移入引擎作为唯一实现研究脚本改为转发导入
这样回测口径与 web 图表永远一致
命名说明缠论原文里没有第四类买卖点 B4/S4 也不只是B3/S3 提前几根
两者的选样口径不同统计性质符号相反故单独立类形态条件是实时可判的
中枢已成 -> 收盘突破 zg -> 收盘未跌回中枢 -> 重新上行
最后一步发生的当根就能下单滞后约 2 而引擎 B3/S3 要等 pullback_bi.sure_time
滞后 9~10 但差别不止滞后
判据 引擎用笔端点事后判回拉笔低点 >= zg本函数用收盘价实时判
方向 引擎由离开笔方向决定本函数由收盘从哪一侧突破决定
口径 627 个中枢里引擎发 625 个信号几乎不筛本函数只认 212 34%
被拒的多数是中枢确认时价格早已离开此后再没回来的历史区间
step30 同条件对拍同一套 pure 笔中枢同一组过滤器同样的 1.5/3.0/48 出场
原始 PF +大级别同向 +同向+阶梯 胜率 t值
引擎 B3/S3 0.66 0.66 0.71 27.4% -18.76
本函数 B4/S4 1.59 1.85 2.26 47.1% +10.09
同一组过滤器对 B4 有效 B3 无效滞后差解释不了这一点入场后移 1~4 根只是
PF 3.18->2.53 的平滑衰减 SL1.5/TP3.0 下随机入场胜率约 33%引擎那 27.4%
低于随机它选中的是一批系统性反向的样本不是晚了所以差
require_touch 控制是否强求回抽碰到中枢边界step32/33 的实测表明强求反而更差
这等于排除掉突破后一去不回头的强势段而那正是缠论里最强的趋势形态
故默认 False笔数 +21%滞后 -1 PF 2.262.37
判据本身收盘越过前一根极值没有独立预测力裸用 15 万笔样本 PF 0.95
把中枢换成近20根高点这类伪阻力位后 PF 0.90alpha 全部来自中枢结构
判据只负责在这个已知价位上确认动能恢复它也不能用于识别笔端点
入场前的回抽极值命中笔端点±2根的比例 19.3%低于随机基准 22%
全部判定只使用当根及之前的数据无未来函数
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from chanlun.core.ChanEnum import Chan_FX_TYPE
# 中枢的「可用时刻」取第几笔的确认时间。
# -1 = 最后一笔(历史默认)。中枢每吸收一根K线,最后一笔就可能后移,
# 于是 available_ts 跟着漂——这是右边缘重画的根因(见 HANDOFF §5.41)。
# 2 = 第三笔。三笔重叠即中枢成立,此后不再变,理论上更早也更稳。
# ⛔ step47 已判定 bis[2] 作废(毛 R 0.933 → 0.000,见 HANDOFF §5.42)。
# 开关保留只为可复现那次 A/B,**不要改默认值**。
import os as _os
AVAIL_BI_INDEX = -1
def _resolve_avail_bi(avail_bi: int | None) -> int:
"""优先级:显式入参 > 环境变量 CHAN_AVAIL_BI > 模块默认。
环境变量在调用时读取而非 import ProcessPoolExecutor fork 启动方式下
子进程会继承已 import 的模块import 时读就固化成父进程的值了
"""
if avail_bi is not None:
return avail_bi
return int(_os.environ.get("CHAN_AVAIL_BI", AVAIL_BI_INDEX))
def timestamps_ms(src: pd.DataFrame) -> np.ndarray:
"""取毫秒时间戳。研究侧的 df 自带 timestampweb 侧的不一定,故按 date 回退。
回退写法不能用 `date.astype("int64") // 10**6`该值单位取决于列精度
对毫秒精度的列会把时间戳砸平
"""
if "timestamp" in src.columns:
return src["timestamp"].to_numpy()
d = pd.to_datetime(src["date"])
if getattr(d.dt, "tz", None) is None:
d = d.dt.tz_localize("UTC")
return (d.dt.tz_convert("UTC").dt.tz_localize(None)
.astype("datetime64[ms]").astype("int64").to_numpy())
def ensure_timestamp(df: pd.DataFrame) -> pd.DataFrame:
"""保证 df 带 timestamp 列,缺失时补一份副本,不改动调用方的对象。"""
if "timestamp" in df.columns:
return df
out = df.copy()
out["timestamp"] = timestamps_ms(out)
return out
def build_htf_zones(df_htf: pd.DataFrame, tf: str, chan=None, avail_bi: int | None = None) -> pd.DataFrame:
"""算 pure 笔中枢,返回带生效时间的区间表。
available_ts 该中枢最早可被使用的时间戳其确认时刻
传入已构建好的 chan 可避免重复跑一遍 pipeline大数据集上省一半时间
"""
if chan is None:
from chanlun import TF_DF
chan = TF_DF(df_htf, 1, tf)
zs_list = chan.cal_bi_zs_list_pure(chan.bi_list)
# 用引擎自己的 dataframe 对齐,避免调用方传入的 df 与引擎内部行数不一致
src = chan.dataframe if getattr(chan, "dataframe", None) is not None else df_htf
return zones_from_zs_list(zs_list, src, avail_bi=avail_bi)
def zones_from_zs_list(zs_list, src: pd.DataFrame, avail_bi: int | None = None) -> pd.DataFrame:
"""把已算好的 pure 笔中枢转成区间表。
调用方手工跑过 cal_bi_zs_list_pure 时走这里免得再算一遍web analyze_chan
就是这种用法
"""
ts_of = dict(zip(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"), timestamps_ms(src)))
# 中枢可用时刻取第几笔。在循环外解析一次,别让每个中枢都去读一遍环境变量。
i = _resolve_avail_bi(avail_bi)
rows = []
for zs in zs_list:
bis = getattr(zs, "bi_list", [])
if not bis:
continue
# 笔数不够时退回最后一笔(i=2 需要至少 3 笔才成立)
key_bi = bis[i] if (i == -1 or len(bis) > i) else bis[-1]
sure_key = str(getattr(key_bi, "sure_time", "") or "")
end_key = str(getattr(key_bi, "end_time", "") or "")
avail = ts_of.get(sure_key) or ts_of.get(end_key)
if avail is None:
continue
start_key = str(bis[0].start_time)
rows.append({
"zg": float(zs.zg), "zd": float(zs.zd),
"gg": float(getattr(zs, "gg", zs.zg)), "dd": float(getattr(zs, "dd", zs.zd)),
"start_ts": ts_of.get(start_key, avail),
"available_ts": int(avail),
})
out = pd.DataFrame(rows)
return out.sort_values("available_ts").reset_index(drop=True) if not out.empty else out
def add_zone_ladder(zones: pd.DataFrame) -> pd.DataFrame:
"""标注每个中枢相对前一个中枢是否同向推进(缠论「趋势 vs 盘整」)。
z_above / z_below 分别对应向上向下推进买信号要求 z_above卖信号要求 z_below
30m/2h PF 2.72 升到 3.41
"""
out = zones.copy()
if out.empty:
out["z_above"], out["z_below"] = pd.Series(dtype=bool), pd.Series(dtype=bool)
return out
pg, pdn = out["zg"].shift(), out["zd"].shift()
out["z_above"] = (out["zd"] > pg).fillna(False)
out["z_below"] = (out["zg"] < pdn).fillna(False)
return out
def htf_fx_timeline(chan_htf, df_htf: pd.DataFrame | None = None) -> pd.DataFrame:
"""把大级别分型压成一条按确认时间排序的时间线。
confirm_ts 是该分型最早可被使用的时刻timestamp 是K线开盘时刻而分型要等这根K线
收盘才算数所以整体后移一个大级别周期否则小级别会提前一整根大级别K线拿到信号
只取方向与确认时刻同向过滤用不到背驰强度省掉 MACD 面积计算
"""
src = chan_htf.dataframe if getattr(chan_htf, "dataframe", None) is not None else df_htf
if src is None or len(src) == 0:
return pd.DataFrame(columns=["confirm_ts", "fx_ts", "direction", "price"])
ts = timestamps_ms(src)
idx_of = {t: i for i, t in enumerate(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"))}
period = int(np.median(np.diff(ts))) if len(ts) > 1 else 0
rows = []
for klc in getattr(chan_htf, "klc_list", []):
if klc.fx not in (Chan_FX_TYPE.TOP, Chan_FX_TYPE.BOTTOM):
continue
if klc.next is None or klc.next.end_klu is None:
continue
e_key, c_key = str(klc.end_time), str(klc.next.end_klu.time)
if e_key not in idx_of or c_key not in idx_of:
continue
fx_idx, confirm_idx = idx_of[e_key], idx_of[c_key]
if confirm_idx <= fx_idx:
continue
d = 1 if klc.fx == Chan_FX_TYPE.BOTTOM else -1
rows.append({
"confirm_ts": int(ts[confirm_idx]) + period,
"fx_ts": int(ts[fx_idx]),
"direction": d,
"price": float(klc.low if d == 1 else klc.high),
})
out = pd.DataFrame(rows)
if out.empty:
return pd.DataFrame(columns=["confirm_ts", "fx_ts", "direction", "price"])
return out.sort_values("confirm_ts").reset_index(drop=True)
def attach_htf_agree(sig: pd.DataFrame, df_ltf: pd.DataFrame, tl: pd.DataFrame) -> pd.DataFrame:
"""给每个小级别信号挂上「入场时刻之前最近的大级别分型」是否同向。
产出 htf_dir+1 / -1 htf_agree1 同向 / 0 反向 / NaN 无可用分型
"""
out = sig.copy()
if sig.empty or tl.empty:
out["htf_dir"] = np.nan
out["htf_agree"] = np.nan
return out
ts_ltf = timestamps_ms(df_ltf)
entry_ts = ts_ltf[out["entry_idx"].to_numpy().astype(int)]
k = np.searchsorted(tl["confirm_ts"].to_numpy(), entry_ts, side="right") - 1
valid = k >= 0
k_safe = np.clip(k, 0, len(tl) - 1)
fx_dir = tl["direction"].to_numpy()[k_safe].astype(float)
out["htf_dir"] = np.where(valid, fx_dir, np.nan)
out["htf_agree"] = np.where(
valid, (fx_dir == out["direction"].to_numpy()).astype(float), np.nan
)
return out
def attach_zone_ladder(sig: pd.DataFrame, zones: pd.DataFrame) -> pd.DataFrame:
"""按信号方向取该中枢的阶梯标记:买看 z_above、卖看 z_below。
zone_i find_fast_bsp3 enumerate 出的位置序号故用 iloc 定位
"""
out = sig.copy()
if sig.empty:
out["ladder_ok"] = pd.Series(dtype=bool)
return out
z = zones if "z_above" in zones.columns else add_zone_ladder(zones)
above = z["z_above"].to_numpy()
below = z["z_below"].to_numpy()
zi = out["zone_i"].to_numpy().astype(int)
ok = np.where(out["direction"].to_numpy() == 1, above[zi], below[zi])
out["ladder_ok"] = ok.astype(bool)
return out
def find_fast_bsp3(
df: pd.DataFrame,
zones: pd.DataFrame,
scan: int = 200,
pullback_win: int = 30,
tol: float = -1.0,
max_per_zone: int = 1,
diag: dict | None = None,
require_touch: bool = False,
) -> pd.DataFrame:
"""扫描每个中枢,找突破后回抽不回中枢的入场点。
zones 需含 zg / zd / available_ts available_ts 已是可用时刻
max_per_zone > 1 同一中枢在首次入场后继续往后找二次三次突破回抽
用来检验趋势里同一中枢反复给机会是否值得做
tol 算作回抽中的边界容差require_touch=False 时它不再是入场门槛
仅决定哪些K线被视为回抽中而跳过转强判定 tol 越大入场越晚
默认负值 = 完全禁用该跳过突破后每根都检查转强滞后压到 2.2
step34 实测滞后与收益严格单调9.9 PF1.88 / 5.8 2.37 / 2.2 2.86
返回列
entry_idx 实时可下单的K线
direction +1 三买 / -1 三卖
bo_idx 突破根
pb_idx 回抽极值根
lag entry_idx - bo_idx
depth 回抽深度相对中枢边界负值表示曾插入中枢
occ 这是该中枢的第几次入场
"""
if zones.empty:
return pd.DataFrame()
ts = df["timestamp"].to_numpy()
close = df["close"].to_numpy(dtype=float)
high = df["high"].to_numpy(dtype=float)
low = df["low"].to_numpy(dtype=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"])
if zg <= zd:
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
edge = zg if d == 1 else zd
# 第二步:突破后监控回抽,回抽不跌回中枢且重新顺势 -> 入场
touched = False
pb_idx = None
pb_ext = None
entry_idx = None
fell_back = False
for j in range(bo_idx + 1, min(bo_idx + pullback_win + 1, n)):
# 收盘跌回中枢 -> 突破失效
if zd <= close[j] <= zg:
fell_back = True
break
# 回抽触及边界附近(允许 tol 的毛刺)
near = (low[j] <= edge * (1 + tol)) if d == 1 else (high[j] >= edge * (1 - tol))
if near:
touched = True
ext = low[j] if d == 1 else high[j]
if pb_ext is None or ((ext < pb_ext) if d == 1 else (ext > pb_ext)):
pb_ext, pb_idx = ext, j
continue
# 回抽后重新顺势:收盘创出前一根之上(三买)/ 之下(三卖)
# require_touch=False 时不强求回抽碰到中枢边界,
# 这样「突破后一去不回头」的强势段也能收进来。
if (touched and pb_idx is not None) or not require_touch:
go = close[j] > high[j - 1] if d == 1 else close[j] < low[j - 1]
if go:
entry_idx = j
break
if entry_idx is None:
if occ == 1:
note("×突破后跌回中枢" if fell_back
else "×回抽未触及边界" if not touched
else "×触及边界但未转强")
# 这次突破没走成,从突破点之后继续找下一次
cursor = bo_idx + 1
continue
if pb_ext is None:
# 未触及边界就转强(require_touch=False):取突破至入场间的实际极值
seg = slice(bo_idx + 1, entry_idx + 1)
pb_ext = float(low[seg].min() if d == 1 else high[seg].max())
pb_idx = int((low[seg].argmin() if d == 1 else high[seg].argmax())
+ bo_idx + 1)
if occ == 1:
note("√成交")
# 回抽深度:>0 表示未插入中枢,越大表示回抽越浅
depth = (pb_ext - zg) / zg if d == 1 else (zd - pb_ext) / zd
rows.append({
"entry_idx": entry_idx, "direction": d,
"bo_idx": bo_idx, "pb_idx": pb_idx,
"lag": entry_idx - bo_idx,
"depth": depth,
"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
# 相邻中枢可能突破到同一根K线,同一时刻只能有一个仓位,保留最早成型的那个
return (out.sort_values(["entry_idx", "occ", "zone_i"])
.drop_duplicates("entry_idx", keep="first")
.reset_index(drop=True))
+54 -23
View File
@@ -1,6 +1,6 @@
from decimal import Decimal
import ChanKLC
from ChanEnum import Chan_BI_DIR
import chanlun.core.ChanKLC as ChanKLC
from chanlun.core.ChanEnum import Chan_BI_DIR
class ChanBI():
def __init__(self, klc: ChanKLC, index, ddir=Chan_BI_DIR.UP):
self.start_klc = klc
@@ -15,10 +15,12 @@ class ChanBI():
self.sure_time = None
self.klc_list = []
self.klc_list.append(klc)
self._klc_idx = {klc.index}
self.end_time = klc.end_time
self.start_time = klc.start_time
self.macd_hist = 0
self.macd_div = 0
self._macd_hist = 0
self._macd_div = 0
self._macd_dirty = False
self.seg = None
self.height = 0
self.width = 0
@@ -33,26 +35,57 @@ class ChanBI():
def set_seg(self, seg):
self.seg = seg
self.seg_index = len(seg.bi_list)-1
# macd_hist / macd_div 改为惰性。原来 add_klc 每加一根 KLC 就把整笔的
# 所有 KLU 重新累加一遍,是 O(k²);而这两个值只有背驰判定(bsp.py)在读,
# lean 模式下 bsp 根本不算,等于全程白算。这里只标脏,取值时才算。
# 语义不变:klc_list 只增不减(set_start_klc 会重置并同时清脏),
# dir 在 klc 累加期间固定,所以延后到读取时算与逐次重算结果相同。
@property
def macd_hist(self):
if self._macd_dirty:
self.cal_macdhist()
return self._macd_hist
@macd_hist.setter
def macd_hist(self, v):
self._macd_hist = v
self._macd_dirty = False
@property
def macd_div(self):
self.cal_macd_div()
return self._macd_div
@macd_div.setter
def macd_div(self, v):
self._macd_div = v
def set_macdhist(self, macd_hist):
self.macd_hist = macd_hist
def set_macd_div(self, macd_div):
self.macd_div = macd_div
def cal_macd_div(self):
self.macd_div = 0.0
self._macd_div = 0.0
if self.pre and self.pre.pre:
if self.pre.pre.macd_hist == 0:
self.macd_div = 0.0
self._macd_div = 0.0
else:
self.macd_div = self.macd_hist / self.pre.pre.macd_hist
self._macd_div = self.macd_hist / self.pre.pre.macd_hist
#print(self.start_time, self.end_time, self.macd_hist, self.pre.pre.macd_hist, self.macd_div)
def cal_macdhist(self):
self.macd_hist = 0
self._macd_dirty = False
self._macd_hist = 0
up = self.dir == Chan_BI_DIR.UP
acc = 0
for klc in self.klc_list:
for klu in klc.klu_list:
if self.dir == Chan_BI_DIR.UP and klu.macdhist > 0:
self.macd_hist += klu.macdhist
if self.dir == Chan_BI_DIR.DOWN and klu.macdhist < 0:
self.macd_hist -= klu.macdhist
h = klu.macdhist
if up:
if h > 0:
acc += h
elif h < 0:
acc -= h
self._macd_hist = acc
def check_bi_zs_overlap(self):
if self.next and self.next.next:
if self.dir == Chan_BI_DIR.UP:
@@ -95,6 +128,10 @@ class ChanBI():
self.start_klc = klc
self.klc_list = []
self.klc_list.append(klc)
# klc_list 被整个换掉,去重集合与惰性缓存都要跟着重置,
# 否则后续 add_klc 会以为旧下标还在里面而漏加
self._klc_idx = {klc.index}
self._macd_dirty = True
self.high = klc.high
self.low = klc.low
self.dir = ddir
@@ -103,20 +140,14 @@ class ChanBI():
def set_next(self, bi):
self.next = bi
def add_klc(self, klc):
added = False
if len(self.klc_list) > 0:
for index in range(0, len(self.klc_list)):
if self.klc_list[index].index == klc.index:
added = True
break
if not added:
# 去重原本是对 klc_list 线性扫描,配合下面每次全量重算的 macdhist,
# 让「往一笔里加 k 根 KLC」变成 O(k²)。改用下标集合,O(1)。
if klc.index not in self._klc_idx:
self._klc_idx.add(klc.index)
self.klc_list.append(klc)
#print(self.start_time, klc.start_time)
#print(klc.end_time, klc.index)
self.end_klc = klc
self.end_time = klc.klu_list[-1].time
self.cal_macdhist()
self.cal_macd_div()
self._macd_dirty = True
def append_klc_list(self, klc_list):
self.klc_list.append(klc_list)
def get_decimal(self, value):
+2 -2
View File
@@ -1,5 +1,5 @@
from ChanEnum import Chan_ZS_DIR, Chan_ZS_TYPE, Chan_BI_DIR
import ChanBI
from chanlun.core.ChanEnum import Chan_ZS_DIR, Chan_ZS_TYPE, Chan_BI_DIR
import chanlun.core.ChanBI as ChanBI
# 中枢
class ChanBIZS():
def __init__(self, start_bi: ChanBI, index, ddir: Chan_ZS_DIR):
+2 -2
View File
@@ -1,5 +1,5 @@
import ChanBI
from ChanEnum import Chan_BSP_TYPE, Chan_BSP_DIR
import chanlun.core.ChanBI as ChanBI
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_BSP_DIR
class ChanBSP():
def __init__(self, bi: ChanBI, index, type: Chan_BSP_TYPE, ddir: Chan_BSP_DIR, sure_time, zs_count, zs, seg):
+4
View File
@@ -281,6 +281,10 @@ class Chan_BSP_TYPE(Enum):
S1 = auto()
S2 = auto()
S3 = auto()
# 第四类:三类买卖点的低滞后变体,几何位置同 B3/S3,但不等笔确认。
# 见 chanlun/analysis/fast_bsp.py
B4 = auto()
S4 = auto()
NONE = auto()
"""
class Chan_BSP_TYPE(Enum):
+40
View File
@@ -0,0 +1,40 @@
from chanlun.core.ChanEnum import Chan_BSP_DIR, Chan_BSP_TYPE
class ChanFastBSP():
"""第四类买卖点(B4/S4)。
ChanBSP 的区别在于它不挂在笔上fast_bsp 刻意不等笔确认入场点是一根具体的
K线而非一笔的端点所以时间与价格直接取自 K 线没有 bi / klc 可依附
htf_agree ladder_ok 是两个独立的过滤标志不在这里合成上层图表或策略
自己决定要不要用怎么组合
"""
def __init__(self, time, price, ddir, entry_idx, bo_time=None, pb_time=None,
lag=0, depth=0.0, zg=None, zd=None, occ=1,
htf_dir=None, htf_agree=None, ladder_ok=None):
self.time = time
self.price = float(price)
self.dir = ddir
self.type = Chan_BSP_TYPE.B4 if ddir == Chan_BSP_DIR.BUY else Chan_BSP_TYPE.S4
self.entry_idx = int(entry_idx)
self.bo_time = bo_time
self.pb_time = pb_time
self.lag = int(lag)
self.depth = float(depth)
self.zg = float(zg) if zg is not None else None
self.zd = float(zd) if zd is not None else None
self.occ = int(occ)
self.htf_dir = htf_dir
self.htf_agree = htf_agree
self.ladder_ok = ladder_ok
# 入场即成立,没有「等待确认」这个状态;留此字段是为了与 ChanBSP 的序列化对齐
self.is_sure = True
self.start_time = time
self.end_time = time
self.sure_time = time
def __repr__(self):
name = str(self.type).replace('Chan_BSP_TYPE.', '')
return f"<ChanFastBSP {name} {self.time} {self.price} lag={self.lag}>"
+38 -14
View File
@@ -1,12 +1,12 @@
import copy
from typing import Dict, Optional
from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_KLC_FX
from ChanEnum import Chan_K_DIR, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_EMA_POS
from ChanEnum import Chan_EMA_SEMANTIC, Chan_BSP_TYPE, Chan_KLC_STATE, Chan_FX
import ChanKLU
import ChanCTime
import Chan_FX_Box
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_KLC_FX
from chanlun.core.ChanEnum import Chan_K_DIR, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_EMA_POS
from chanlun.core.ChanEnum import Chan_EMA_SEMANTIC, Chan_BSP_TYPE, Chan_KLC_STATE, Chan_FX
import chanlun.core.ChanKLU as ChanKLU
import chanlun.core.ChanCTime as ChanCTime
import chanlun.core.Chan_FX_Box as Chan_FX_Box
# 根据结合律合并K线后的K线
class ChanKLC():
def __init__(self, klu: ChanKLU, index, ddir=Chan_KLINE_DIR.UP):
@@ -63,10 +63,11 @@ class ChanKLC():
self.bsp = False
self.bsp_type = Chan_BSP_TYPE.NONE
# EMA状态字典:key为EMA名称,value为 {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC}
self.ema_status = {}
self._ema_status = {}
self._ema_status_dirty = False
# 向后兼容:保留 ema52_status 和 ema52_pos
self.ema52_status = 0
self.ema52_pos = Chan_EMA_POS.UNKNOWN
self._ema52_status = 0
self._ema52_pos = Chan_EMA_POS.UNKNOWN
self.bb2633upper = klu.bb2633upper
self.bb2633lower = klu.bb2633lower
self.bb2633middle = klu.bb2633middle
@@ -283,21 +284,44 @@ class ChanKLC():
'ema156': self.ema156,
'ema208': self.ema208,
}
self.ema_status = {}
self._ema_status_dirty = False
self._ema_status = {}
for name, value in ema_configs.items():
# 按 EMA 值的百分比自动计算阈值
threshold = abs(value) * self.threshold_pct if value and self.threshold_pct > 0 else 0
pos = ChanKLC.cal_ema_pos(self.high, self.low, self.close, value, threshold)
semantic = ChanKLC.cal_ema_semantic(pos, self.dir, self.ema_dir)
self.ema_status[name] = {
self._ema_status[name] = {
'pos': pos,
'semantic': semantic,
'value': value,
'threshold': threshold,
}
# 向后兼容
self.ema52_pos = self.ema_status['ema52']['pos']
self.ema52_status = ChanKLC.semantic_to_int(self.ema_status['ema52']['semantic'])
self._ema52_pos = self._ema_status['ema52']['pos']
self._ema52_status = ChanKLC.semantic_to_int(self._ema_status['ema52']['semantic'])
# 以下三个改成惰性求值。原来 set_end_klu 每次合并 KLU 都会立刻重算一遍,
# 实测占 TF_DF 构建的约 25%,而全仓(含前端)没有任何地方读取它的产出。
# 保留属性形式是为了任何外部读取仍拿到正确值,只是推迟到真被读时才算。
@property
def ema_status(self):
if self._ema_status_dirty:
self.cal_all_ema_status()
return self._ema_status
@property
def ema52_pos(self):
if self._ema_status_dirty:
self.cal_all_ema_status()
return self._ema52_pos
@property
def ema52_status(self):
if self._ema_status_dirty:
self.cal_all_ema_status()
return self._ema52_status
def get_ema_pos(self, ema_name):
"""获取指定EMA的客观位置,如 klc.get_ema_pos('ema24')"""
if ema_name in self.ema_status:
@@ -448,7 +472,7 @@ class ChanKLC():
klu.set_klc(self)
self.klc_dir = Chan_KLINE_DIR.UP if self.close > self.open else Chan_KLINE_DIR.DOWN
self.cal_indicators()
self.cal_all_ema_status()
self._ema_status_dirty = True
if self.open > self.high:
self.open = self.high
if self.close > self.high:
+34 -21
View File
@@ -1,4 +1,4 @@
from ChanEnum import Chan_FX_TYPE, Chan_KLU_TYPE, Chan_K_DIR, Chan_MACD_STATE, Chan_MACDHIST_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN, Chan_KLC_FX
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLU_TYPE, Chan_K_DIR, Chan_MACD_STATE, Chan_MACDHIST_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN, Chan_KLC_FX
class ChanKLU:
def __init__(self, time, open, high, low, close, volume):
# _time, _close, _open, _high, _low, _extra_info={}
@@ -160,27 +160,40 @@ class ChanKLU:
return False
else:
return True
# 属性名 ← dataframe 列名。两条赋值路径(逐行 Series / 预取 ndarray)共用这张表,
# 免得将来加指标时只改一处、另一处静默漏掉。
INDICATOR_FIELDS = (
('macd', 'macd'), ('signal', 'macdsignal'), ('macdhist', 'macdhist'),
('ema26', 'ema26'), ('ema52', 'ema52'), ('ema24', 'ema24'),
('ema104', 'ema104'), ('ema156', 'ema156'), ('ema208', 'ema208'),
('ema13', 'ema13'), ('ema7', 'ema7'), ('ema5', 'ema5'),
('rsi', 'rsi'), ('volume_ratio', 'volume_ratio'),
('bb52upper', 'bb52upper'), ('bb52lower', 'bb52lower'),
('bb2633upper', 'bb2633upper'), ('bb2633lower', 'bb2633lower'),
('bb2633middle', 'bb2633middle'), ('ma5', 'ma5'),
)
def set_indicators(self, item):
self.macd = float(item['macd']) if 'macd' in item and item['macd'] else 0
self.signal = float(item['macdsignal']) if 'macdsignal' in item and item['macdsignal'] else 0
self.macdhist = float(item['macdhist']) if 'macdhist' in item and item['macdhist'] else 0
self.ema26 = float(item['ema26']) if 'ema26' in item and item['ema26'] else 0
self.ema52 = float(item['ema52']) if 'ema52' in item and item['ema52'] else 0
self.ema24 = float(item['ema24']) if 'ema24' in item and item['ema24'] else 0
self.ema104 = float(item['ema104']) if 'ema104' in item and item['ema104'] else 0
self.ema156 = float(item['ema156']) if 'ema156' in item and item['ema156'] else 0
self.ema208 = float(item['ema208']) if 'ema208' in item and item['ema208'] else 0
self.ema13 = float(item['ema13']) if 'ema13' in item and item['ema13'] else 0
self.ema7 = float(item['ema7']) if 'ema7' in item and item['ema7'] else 0
self.rsi = float(item['rsi']) if 'rsi' in item and item['rsi'] else 0
self.volume_ratio = float(item['volume_ratio']) if 'volume_ratio' in item and item['volume_ratio'] else 0
self.bb52upper = float(item['bb52upper']) if 'bb52upper' in item and item['bb52upper'] else 0
self.bb52lower = float(item['bb52lower']) if 'bb52lower' in item and item['bb52lower'] else 0
self.bb2633upper = float(item['bb2633upper']) if 'bb2633upper' in item and item['bb2633upper'] else 0
self.bb2633lower = float(item['bb2633lower']) if 'bb2633lower' in item and item['bb2633lower'] else 0
self.bb2633middle = float(item['bb2633middle']) if 'bb2633middle' in item and item['bb2633middle'] else 0
self.ma5 = float(item['ma5']) if 'ma5' in item and item['ma5'] else 0
self.ema5 = float(item['ema5']) if 'ema5' in item and item['ema5'] else 0
"""单根赋值。增量追加时每次只有一根,走这条即可。
原写法是 `float(item[c]) if c in item and item[c] else 0`其中的真值判断
是空转值为 0.0 float(0.0) 仍是 0值为 NaN NaN 为真值照样透传
唯一起作用的是列不存在则填 0所以这里只保留那一层
"""
for attr, col in self.INDICATOR_FIELDS:
v = item[col] if col in item else 0
setattr(self, attr, float(v) if v else 0)
def set_indicators_from(self, cols, i):
"""从预取的 {列名: ndarray} 按下标赋值,语义与 set_indicators 相同。
全量构建时用这条避免每根 `df.iloc[i]` 构造一个 Series再在其上做
几十次逐键查找那是 TF_DF 构建 96% 的耗时所在
"""
for attr, col in self.INDICATOR_FIELDS:
arr = cols.get(col)
v = arr[i] if arr is not None else 0
setattr(self, attr, float(v) if v else 0)
def cal_macd_state(self):
# 按定义精简实现:优先级 CROSS0 > 位置(HIGH/HE/RETURN_ZERO) > NEAR0 > UNKNOWN
# 首条或缺前一根
+3 -3
View File
@@ -1,9 +1,9 @@
import copy
from typing import Dict, Optional
from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR
import ChanKLU
from ChanBI import ChanBI
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR
import chanlun.core.ChanKLU as ChanKLU
from chanlun.core.ChanBI import ChanBI
class ChanSBI():
def __init__(self, start_bi: ChanBI, index, dir=Chan_BI_DIR.UP):
+4 -4
View File
@@ -1,10 +1,10 @@
import copy
from typing import Dict, Optional
from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_SEG_DIR, Chan_BI_DIR, Chan_ZS_DIR
import ChanCTime
from ChanBI import ChanBI
from ChanBIZS import ChanBIZS
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_SEG_DIR, Chan_BI_DIR, Chan_ZS_DIR
import chanlun.core.ChanCTime as ChanCTime
from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS
class ChanSEG():
def __init__(self, start_bi: ChanBI, index, ddir=Chan_SEG_DIR.UP, pre_end_bi: ChanBI = None):
self.start_bi = start_bi
+4 -3
View File
@@ -1,8 +1,9 @@
from typing import Dict, Optional
import ChanKLC, ChanSEG
import ChanCTime
from ChanEnum import Chan_ZS_DIR
import chanlun.core.ChanKLC as ChanKLC
import chanlun.core.ChanSEG as ChanSEG
import chanlun.core.ChanCTime as ChanCTime
from chanlun.core.ChanEnum import Chan_ZS_DIR
# 中枢
class ChanZS():
def __init__(self, start_seg: ChanSEG, index, ddir: Chan_ZS_DIR):
@@ -1,8 +1,8 @@
from ChanKLU import ChanKLU
from ChanEnum import Chan_MACD_STATE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIR, Chan_MACDUNITTF_TYPE
from ChanMACDSeg import ChanMACDSeg
from ChanMACDUnitTF import ChanMACDUnitTF
from ChanMACDHistSet import ChanMACDHistSet
from chanlun.core.ChanKLU import ChanKLU
from chanlun.core.ChanEnum import Chan_MACD_STATE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIR, Chan_MACDUNITTF_TYPE
from chanlun.indicators.ChanMACDSeg import ChanMACDSeg
from chanlun.indicators.ChanMACDUnitTF import ChanMACDUnitTF
from chanlun.indicators.ChanMACDHistSet import ChanMACDHistSet
class ChanMACD():
def __init__(self, klu_list: list[ChanKLU]):
@@ -1,4 +1,4 @@
from ChanEnum import Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACD_STATE
from chanlun.core.ChanEnum import Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACD_STATE
class ChanMACDHistSet():
def __init__(self, index, start_time, start_klu, pre_histset, dir):
@@ -1,4 +1,4 @@
from ChanEnum import Chan_MACDSEG_DIR
from chanlun.core.ChanEnum import Chan_MACDSEG_DIR
class ChanMACDSeg():
@@ -1,4 +1,4 @@
from ChanEnum import Chan_MACD_STATE, Chan_MACDUNITTF_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACDUNITTF_TYPE
from chanlun.core.ChanEnum import Chan_MACD_STATE, Chan_MACDUNITTF_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACDUNITTF_TYPE
class ChanMACDUnitTF():
+196
View File
@@ -0,0 +1,196 @@
"""Drop-in replacement for the `talib.abstract` calls this project makes.
Same call signatures, same column names, same NaN warm-up lengths, so call
sites only change their import line.
Only what the codebase actually uses is implemented: SMA, MA, EMA, RSI, ATR,
MACD, BBANDS. Numerical agreement with TA-Lib is enforced by
`chanlun/tests/test_ta_compat.py`, which skips when talib is absent.
The warm-up conventions below are TA-Lib's, not the textbook ones, and they
differ between functions getting them wrong shifts every downstream Chan
structure by a bar:
SMA/BBANDS first value at index period-1
EMA seeded with the SMA of the first `period` values, at index period-1
RSI/ATR Wilder smoothing (alpha = 1/period), first value at index period
"""
from __future__ import annotations
import numpy as np
import pandas as pd
__all__ = ["SMA", "MA", "EMA", "RSI", "ATR", "MACD", "BBANDS"]
def _series(data, price: str = "close") -> pd.Series:
"""Accept the abstract-API shapes: DataFrame, Series, or ndarray."""
if isinstance(data, pd.DataFrame):
return data[price].astype(float)
if isinstance(data, pd.Series):
return data.astype(float)
return pd.Series(np.asarray(data, dtype=float))
def _recursive(values: np.ndarray, seed: float, start: int, alpha: float, n: int) -> np.ndarray:
"""out[start] = seed; out[i] = alpha*values[i] + (1-alpha)*out[i-1].
Delegates the recursion to pandas' C implementation rather than a Python
loop `research/` runs this over long histories.
"""
out = np.full(n, np.nan)
if start >= n:
return out
tail = values[start:].astype(float).copy()
tail[0] = seed
out[start:] = pd.Series(tail).ewm(alpha=alpha, adjust=False).mean().to_numpy()
return out
def SMA(data, timeperiod: int = 30, price: str = "close") -> pd.Series:
s = _series(data, price)
return s.rolling(window=timeperiod, min_periods=timeperiod).mean()
def MA(data, timeperiod: int = 30, matype: int = 0, price: str = "close") -> pd.Series:
if matype != 0:
raise NotImplementedError(f"MA matype={matype} is not used by this codebase")
return SMA(data, timeperiod, price=price)
def _ema(x: np.ndarray, period: int, start: int) -> np.ndarray:
"""EMA whose first output lands on `start`, seeded by the SMA of the
`period` values ending there.
`start` is a parameter because MACD needs the fast EMA to begin later than
it naturally would; see the note in MACD().
"""
n = x.size
if n <= start or start < period - 1:
return np.full(n, np.nan)
seed = x[start - period + 1: start + 1].mean()
return _recursive(x, seed, start, 2.0 / (period + 1.0), n)
def EMA(data, timeperiod: int = 30, price: str = "close") -> pd.Series:
s = _series(data, price)
x = s.to_numpy(dtype=float)
return pd.Series(_ema(x, timeperiod, timeperiod - 1), index=s.index)
def RSI(data, timeperiod: int = 14, price: str = "close") -> pd.Series:
s = _series(data, price)
x = s.to_numpy(dtype=float)
n = x.size
out = np.full(n, np.nan)
if n <= timeperiod:
return pd.Series(out, index=s.index)
delta = np.diff(x)
gain = np.where(delta > 0.0, delta, 0.0)
loss = np.where(delta < 0.0, -delta, 0.0)
# delta[k] corresponds to bar k+1, so the first `timeperiod` deltas seed bar `timeperiod`.
alpha = 1.0 / timeperiod
avg_gain = _recursive(gain, gain[:timeperiod].mean(), timeperiod - 1, alpha, n - 1)
avg_loss = _recursive(loss, loss[:timeperiod].mean(), timeperiod - 1, alpha, n - 1)
ag = avg_gain[timeperiod - 1:]
al = avg_loss[timeperiod - 1:]
with np.errstate(divide="ignore", invalid="ignore"):
rsi = np.where(al == 0.0, 100.0, 100.0 - 100.0 / (1.0 + ag / al))
out[timeperiod:] = rsi
return pd.Series(out, index=s.index)
def ATR(data, timeperiod: int = 14) -> pd.Series:
if not isinstance(data, pd.DataFrame):
raise TypeError("ATR needs a DataFrame with high/low/close")
high = data["high"].to_numpy(dtype=float)
low = data["low"].to_numpy(dtype=float)
close = data["close"].to_numpy(dtype=float)
n = high.size
out = np.full(n, np.nan)
if n <= timeperiod:
return pd.Series(out, index=data.index)
prev_close = close[:-1]
tr = np.maximum.reduce([
high[1:] - low[1:],
np.abs(high[1:] - prev_close),
np.abs(low[1:] - prev_close),
])
# tr[k] is bar k+1; the first `timeperiod` true ranges seed bar `timeperiod`.
smoothed = _recursive(tr, tr[:timeperiod].mean(), timeperiod - 1, 1.0 / timeperiod, n - 1)
out[timeperiod:] = smoothed[timeperiod - 1:]
return pd.Series(out, index=data.index)
def MACD(
data,
fastperiod: int = 12,
slowperiod: int = 26,
signalperiod: int = 9,
price: str = "close",
) -> pd.DataFrame:
if slowperiod < fastperiod:
fastperiod, slowperiod = slowperiod, fastperiod
s = _series(data, price)
x = s.to_numpy(dtype=float)
n = x.size
macd = np.full(n, np.nan)
signal = np.full(n, np.nan)
hist = np.full(n, np.nan)
empty = pd.DataFrame({"macd": macd, "macdsignal": signal, "macdhist": hist}, index=s.index)
# Both EMAs emit their first value on the same bar. That makes the slow one
# ordinary, but re-seeds the fast one from the SMA of the `fastperiod`
# values ending there instead of carrying the recursion forward from bar
# fastperiod-1 — the two disagree by ~0.2 on a 100-priced series.
macd_start = slowperiod - 1
if n <= macd_start:
return empty
line = _ema(x, fastperiod, macd_start) - _ema(x, slowperiod, macd_start)
# The signal EMA runs over the MACD line, so everything shifts by another
# signalperiod-1 bars, and TA-Lib trims the MACD line to match.
valid = line[macd_start:]
if valid.size < signalperiod:
return empty
sig = _recursive(
valid, valid[:signalperiod].mean(), signalperiod - 1, 2.0 / (signalperiod + 1.0), valid.size
)
start = macd_start + signalperiod - 1
macd[start:] = line[start:]
signal[macd_start:] = sig
hist = macd - signal
return pd.DataFrame({"macd": macd, "macdsignal": signal, "macdhist": hist}, index=s.index)
def BBANDS(
data,
timeperiod: int = 5,
nbdevup: float = 2.0,
nbdevdn: float = 2.0,
matype: int = 0,
price: str = "close",
) -> pd.DataFrame:
if matype != 0:
raise NotImplementedError(f"BBANDS matype={matype} is not used by this codebase")
s = _series(data, price)
middle = s.rolling(window=timeperiod, min_periods=timeperiod).mean()
# TA-Lib uses the population standard deviation.
std = s.rolling(window=timeperiod, min_periods=timeperiod).std(ddof=0)
return pd.DataFrame(
{
"upperband": middle + nbdevup * std,
"middleband": middle,
"lowerband": middle - nbdevdn * std,
},
index=s.index,
)
+680
View File
@@ -0,0 +1,680 @@
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
from __future__ import annotations
from datetime import timedelta
from decimal import Decimal
import numpy as np
import pandas as pd
from pandas import DataFrame
from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS
from chanlun.core.ChanBSP import ChanBSP
from chanlun.core.ChanEnum import (
Chan_BI_DIR,
Chan_BSP_DIR,
Chan_BSP_TYPE,
Chan_FX_TYPE,
Chan_K_DIR,
Chan_KLC_FX,
Chan_KLC_STATE,
Chan_KLINE_DIR,
Chan_KLU_PATTERN,
Chan_PRICE_TREND,
Chan_SEG_DIR,
Chan_ZS_DIR,
)
from chanlun.core.ChanKLC import ChanKLC
from chanlun.core.ChanKLU import ChanKLU
from chanlun.core.ChanSBI import ChanSBI
from chanlun.core.ChanSEG import ChanSEG
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
from chanlun.indicators.ChanMACD import ChanMACD
class BiBuilderMixin:
def cal_trend(self, klc_list):
"""
基于价格与EMA24/EMA52的位置关系以及MACD/Signal/Hist的方向
为每个KLC打上趋势标签'UP' / 'DOWN' / 'FLAT'
仅设置 klc.trend不影响其它字段
"""
if not klc_list:
return klc_list
last_trend = Chan_PRICE_TREND.UNKNOWN
# 趋势延续性:参考近 N 根已完成的KLC
lookback_n = 5
prev_klcs = []
for klc in klc_list:
price = getattr(klc, 'close', None)
ema24 = getattr(klc, 'ema24', None)
ema52 = getattr(klc, 'ema52', None)
macd_raw = getattr(klc, 'macd', None)
signal_raw = getattr(klc, 'signal', None)
hist_raw = getattr(klc, 'macdhist', None)
macd = macd_raw if macd_raw is not None else 0
signal = signal_raw if signal_raw is not None else 0
hist = hist_raw if hist_raw is not None else 0
rsi = getattr(klc, 'rsi', None)
macd_ready = macd_raw is not None and signal_raw is not None
hist_ready = hist_raw is not None
trend = Chan_PRICE_TREND.UNKNOWN
score = 0
try:
# 有效性
price_valid = price is not None and price != 0
ema24_valid = ema24 is not None and ema24 != 0
ema52_valid = ema52 is not None and ema52 != 0
# 多因子投票
# 1) 均线结构 + 价位
if ema24_valid or ema52_valid:
ma_votes = 0
if ema24_valid and ema52_valid:
ma_votes += 1 if ema24 > ema52 else -1
if price_valid and ema24_valid:
ma_votes += 1 if price > ema24 else 0
if price_valid and ema52_valid:
ma_votes += 1 if price > ema52 else -1
# 限幅,避免相关因子重复计分
score += max(-2, min(2, ma_votes))
# 2) MACD结构
if macd_ready:
score += 1 if macd >= signal else -1
if hist_ready and hist != 0:
score += 1 if hist > 0 else -1
# 3) 动量与均线差分斜率
pre = getattr(klc, 'pre', None)
pre_hist = getattr(pre, 'macdhist', None) if pre else None
if pre:
pre_close = getattr(pre, 'close', None)
if price_valid and pre_close is not None:
score += 1 if price >= pre_close else -1
pre_ema24 = getattr(pre, 'ema24', None)
pre_ema52 = getattr(pre, 'ema52', None)
if ema24_valid and ema52_valid and pre_ema24 not in (None, 0) and pre_ema52 not in (None, 0):
spread_now = ema24 - ema52
spread_pre = pre_ema24 - pre_ema52
score += 1 if spread_now >= spread_pre else -1
# 3.1) MACD柱体动量趋势:考虑 macdhist 的斜率与过零
if hist_ready and pre_hist is not None:
# 柱体斜率:上升加分,下降减分
if hist > pre_hist:
score += 1
elif hist < pre_hist:
score -= 1
# 过零加权:负转正更偏多,正转负更偏空
if pre_hist < 0 and hist > 0:
score += 1
elif pre_hist > 0 and hist < 0:
score -= 1
# 3.2) EMA52 突破/跌破加权
if ema52_valid and price_valid and pre_close is not None and pre_ema52 not in (None, 0):
# 看多突破:从均线下方上破且动量配合
if pre_close <= pre_ema52 and price > ema52 and (hist is None or pre_hist is None or hist >= pre_hist):
score += 1
# 看空跌破:从均线上方下破且动量配合
if pre_close >= pre_ema52 and price < ema52 and (hist is None or pre_hist is None or hist <= pre_hist):
score -= 1
# 3.3) EMA52 支撑/阻力触碰(非强穿越)
if ema52_valid and price_valid:
low_v = getattr(klc, 'low', None)
high_v = getattr(klc, 'high', None)
if low_v is not None and high_v is not None and ema52 not in (None, 0):
# 触碰容差(相对EMA52的0.15%
touch_tol = 0.0015
# 作为支撑:收盘在上,最低靠近EMA52
near_support_touch = (price > ema52) and (abs(low_v - ema52) / abs(ema52) <= touch_tol)
# 作为阻力:收盘在下,最高靠近EMA52
near_resistance_touch = (price < ema52) and (abs(high_v - ema52) / abs(ema52) <= touch_tol)
if near_support_touch:
# 若动量不弱,则更偏多
score += 1 if (hist is None or pre_hist is None or hist >= pre_hist) else 0
if near_resistance_touch:
# 若动量不强,则更偏空
score -= 1 if (hist is None or pre_hist is None or hist <= pre_hist) else 0
# 3.4) 多次对 EMA52 的"拒绝"配合 MACD 逆向:易形成压/支并反向
# 统计近窗口内的上/下拒绝次数:
# - 上拒绝:价格位于 EMA52 下方,最高触及/越过 EMA52 但收盘仍在下方
# - 下拒绝:价格位于 EMA52 上方,最低触及/跌破 EMA52 但收盘仍在上方
recent_up_rejects = 0
recent_down_rejects = 0
if ema52_valid:
window_rej = prev_klcs[-lookback_n:] if len(prev_klcs) > 0 else []
rej_tol = 0.0015
for wk in window_rej:
wk_close = getattr(wk, 'close', None)
wk_ema52 = getattr(wk, 'ema52', None)
wk_high = getattr(wk, 'high', None)
wk_low = getattr(wk, 'low', None)
if wk_close is None or wk_ema52 in (None, 0):
continue
# 上拒绝(阻力):下方多次试图上破但未站上
if wk_close < wk_ema52 and wk_high is not None:
if wk_high >= wk_ema52 or abs(wk_high - wk_ema52) / abs(wk_ema52) <= rej_tol:
recent_up_rejects += 1
# 下拒绝(支撑):上方多次试图下破但未跌破
if wk_close > wk_ema52 and wk_low is not None:
if wk_low <= wk_ema52 or abs(wk_low - wk_ema52) / abs(wk_ema52) <= rej_tol:
recent_down_rejects += 1
# 定义 MACD 的方向偏好
macd_bias_up = macd_ready and (macd >= signal) and (not hist_ready or pre_hist is None or hist >= pre_hist)
macd_bias_down = macd_ready and (macd <= signal) and (not hist_ready or pre_hist is None or hist <= pre_hist)
# 若多次上拒绝且 MACD 偏空,则更偏向下行;若多次下拒绝且 MACD 偏多,则更偏向上行
if recent_up_rejects >= 2 and macd_bias_down:
score -= 2
if recent_down_rejects >= 2 and macd_bias_up:
score += 2
# 4) RSI 辅助
if rsi is not None:
if rsi >= 55:
score += 1
elif rsi <= 45:
score -= 1
# 5) 指标未就绪回退(EMA/MACD缺失时,用动量与RSI辅助,延续趋势)
has_full_ind = ema24_valid and ema52_valid and not (macd == 0 and signal == 0 and hist == 0)
if not has_full_ind:
# 仅根据价动量/RSI做轻量判断,默认延续 last_trend,除非出现强反向
strong_up = False
strong_down = False
pre = getattr(klc, 'pre', None)
if pre:
pre_close = getattr(pre, 'close', None)
if price_valid and pre_close is not None:
strong_up = (price >= pre_close)
strong_down = (price < pre_close)
if rsi is not None:
if rsi >= 60:
strong_up = True
elif rsi <= 40:
strong_down = True
if last_trend == Chan_PRICE_TREND.UP and not strong_down:
trend = Chan_PRICE_TREND.UP
elif last_trend == Chan_PRICE_TREND.DOWN and not strong_up:
trend = Chan_PRICE_TREND.DOWN
else:
trend = Chan_PRICE_TREND.UP if strong_up and not strong_down else (Chan_PRICE_TREND.DOWN if strong_down and not strong_up else Chan_PRICE_TREND.FLAT)
else:
# 6) 震荡过滤(仅当极近EMA52且MACD贴合时判作震荡)
near_flat = False
if price_valid and ema52_valid:
near_ema52 = abs(price - ema52) / abs(ema52) <= 0.0005 # 0.05%
if macd_ready:
macd_scale = max(abs(macd), abs(signal), 1e-6)
near_macd = abs(macd - signal) / macd_scale <= 0.05
else:
near_macd = False
near_flat = near_ema52 and near_macd
# 7) 动态阈值 + 趋势记忆(更强粘滞:趋势中容忍小幅反分)
# 引入过去 N 根KLC 的趋势延续性来动态调整翻转阈值,并结合 EMA52 支撑/阻力触碰强化门槛
force_flip_down = False
force_flip_up = False
if near_flat:
trend = Chan_PRICE_TREND.FLAT
else:
# 计算过去窗口的趋势一致性
window = prev_klcs[-lookback_n:] if len(prev_klcs) > 0 else []
persist_up = 0
persist_down = 0
for wk in window:
if getattr(wk, 'trend', None) == Chan_PRICE_TREND.UP:
persist_up += 1
elif getattr(wk, 'trend', None) == Chan_PRICE_TREND.DOWN:
persist_down += 1
persist_ratio_up = (persist_up / len(window)) if len(window) > 0 else 0
persist_ratio_down = (persist_down / len(window)) if len(window) > 0 else 0
# 基准阈值
down_flip_threshold = -2
up_flip_threshold = 2
# 若最近多为UP,则从UP翻转需更强反向信号;同理对DOWN
if last_trend == Chan_PRICE_TREND.UP and persist_ratio_up >= 0.6:
down_flip_threshold = -3
elif last_trend == Chan_PRICE_TREND.DOWN and persist_ratio_down >= 0.6:
up_flip_threshold = 3
# EMA52 触碰强化门槛:UP时若出现支撑触碰,下翻更难;DOWN时若出现阻力触碰,上翻更难
if ema52_valid and price_valid:
low_v = getattr(klc, 'low', None)
high_v = getattr(klc, 'high', None)
if low_v is not None and high_v is not None and ema52 not in (None, 0):
touch_tol = 0.0015
near_support_touch = (price > ema52) and (abs(low_v - ema52) / abs(ema52) <= touch_tol)
near_resistance_touch = (price < ema52) and (abs(high_v - ema52) / abs(ema52) <= touch_tol)
if last_trend == Chan_PRICE_TREND.UP and near_support_touch:
# 强化维持UP:进一步降低向下翻转阈值
down_flip_threshold = min(down_flip_threshold - 1, -3)
if last_trend == Chan_PRICE_TREND.DOWN and near_resistance_touch:
# 强化维持DOWN:进一步提高向上翻转阈值
up_flip_threshold = max(up_flip_threshold + 1, 3)
# 7.1) 复合拐头信号:MACD/Signal 同向拐头 + hist 连续减弱 + 多次未能越过 EMA52
pre_macd = getattr(pre, 'macd', None) if pre else None
pre_signal = getattr(pre, 'signal', None) if pre else None
macd_slope = (macd - pre_macd) if (macd_ready and pre_macd is not None) else 0
signal_slope = (signal - pre_signal) if (macd_ready and pre_signal is not None) else 0
# hist 连续减弱(绝对值缩小)
hist_seq = []
for wk in prev_klcs[-2:]:
val = getattr(wk, 'macdhist', None)
if val is not None:
hist_seq.append(val)
if hist is not None:
hist_seq.append(hist)
weaken_steps = 0
for i in range(1, len(hist_seq)):
if abs(hist_seq[i]) < abs(hist_seq[i-1]):
weaken_steps += 1
# 近窗口对 EMA52 的"未能站上/跌破"统计(放宽窗口与条件)
window_ema = prev_klcs[-4:] if len(prev_klcs) > 0 else []
no_up_break = False
no_down_break = False
if ema52_valid:
# 未能有效上破:最近若干根收盘大多数不在 EMA52 上方,且高点多次触及/接近
cnt_touch_up = 0
cnt_close_above = 0
for wk in window_ema:
wk_close = getattr(wk, 'close', None)
wk_high = getattr(wk, 'high', None)
wk_ema = getattr(wk, 'ema52', None)
if wk_close is not None and wk_ema not in (None, 0):
if wk_close > wk_ema:
cnt_close_above += 1
if wk_high is not None and (wk_high >= wk_ema or abs(wk_high - wk_ema) / abs(wk_ema) <= 0.0015):
cnt_touch_up += 1
no_up_break = (cnt_close_above <= 1 and cnt_touch_up >= 1 and price <= ema52)
# 未能有效下破:最近若干根收盘大多数不在 EMA52 下方,且低点多次触及/接近
cnt_touch_down = 0
cnt_close_below = 0
for wk in window_ema:
wk_close = getattr(wk, 'close', None)
wk_low = getattr(wk, 'low', None)
wk_ema = getattr(wk, 'ema52', None)
if wk_close is not None and wk_ema not in (None, 0):
if wk_close < wk_ema:
cnt_close_below += 1
if wk_low is not None and (wk_low <= wk_ema or abs(wk_low - wk_ema) / abs(wk_ema) <= 0.0015):
cnt_touch_down += 1
no_down_break = (cnt_close_below <= 1 and cnt_touch_down >= 1 and price >= ema52)
# 若当前为UP趋势,出现明显拐头+hist减弱+未能上破EMA52,则加速看空
if last_trend == Chan_PRICE_TREND.UP and macd_slope < 0 and signal_slope < 0 and weaken_steps >= 1 and no_up_break and macd_bias_down:
score -= 3
down_flip_threshold = max(down_flip_threshold, 0)
force_flip_down = True
# 若当前为DOWN趋势,出现明显拐头+hist减弱+未能下破EMA52,则加速看多
if last_trend == Chan_PRICE_TREND.DOWN and macd_slope > 0 and signal_slope > 0 and weaken_steps >= 1 and no_down_break and macd_bias_up:
score += 3
up_flip_threshold = min(up_flip_threshold, 0)
force_flip_up = True
# 多次对 EMA52 的拒绝配合 MACD 逆向:加速反向翻转(降低相反方向阈值)
if recent_up_rejects >= 2 and macd_bias_down:
# 从 UP 向 DOWN 的翻转更容易
down_flip_threshold = max(down_flip_threshold, -1)
if recent_down_rejects >= 2 and macd_bias_up:
# 从 DOWN 向 UP 的翻转更容易
up_flip_threshold = min(up_flip_threshold, 1)
if force_flip_down:
trend = Chan_PRICE_TREND.DOWN
elif force_flip_up:
trend = Chan_PRICE_TREND.UP
elif last_trend == Chan_PRICE_TREND.UP:
if score <= down_flip_threshold:
trend = Chan_PRICE_TREND.DOWN
else:
trend = Chan_PRICE_TREND.UP
elif last_trend == Chan_PRICE_TREND.DOWN:
if score >= up_flip_threshold:
trend = Chan_PRICE_TREND.UP
else:
trend = Chan_PRICE_TREND.DOWN
else:
# 初始无记忆时,降低进入门槛
if score >= 1:
trend = Chan_PRICE_TREND.UP
elif score <= -1:
trend = Chan_PRICE_TREND.DOWN
else:
trend = Chan_PRICE_TREND.FLAT
except Exception:
trend = Chan_PRICE_TREND.UNKNOWN
# 写回趋势
if klc.end_time is None:
trend = Chan_PRICE_TREND.FLAT
if hasattr(klc, 'set_trend'):
klc.set_trend(trend)
else:
setattr(klc, 'trend', trend)
last_trend = trend
# 更新滑窗:仅向后看
prev_klcs.append(klc)
price_diff = klc.close - klc.pre.close if klc.pre else 0
#if klc.index > len(klc_list) - 10:
#print(klc.start_time, klc.end_time, klc.close, klc.ema24, klc.ema52, klc.macd, klc.signal, klc.macdhist, klc.trend, price_diff, score)
#print(klc.start_time, klc.end_time, klc.trend, price_diff, score)
return klc_list
def get_bi_list(self, dataframe):
bi_list = self.cal_bi_list(self.get_klc_list(dataframe))
#bi_list = self.cal_bi_list_chanlun(self.get_klc_list(dataframe))
return bi_list
def cal_bi_list(self, klc_list):
bi_list = []
last_top = None
last_bottom = None
bi_klc_min = 4
last_fx_klc = None
for klc in klc_list:
if last_fx_klc:
klc.check_klc_state(last_fx_klc)
klc.check_fx_confirmed(last_top, last_bottom)
fx = self.check_fx(klc)
if fx == Chan_FX_TYPE.TOP:
if last_bottom:
if self.check_top_fx(last_bottom, klc) == False:
fx = Chan_FX_TYPE.UNKNOWN
if fx == Chan_FX_TYPE.BOTTOM:
if last_top:
if self.check_bottom_fx(last_top, klc) == False:
#print(klc.end_time, last_top.end_time, "---")
fx = Chan_FX_TYPE.UNKNOWN
# Do nothing
if fx == Chan_FX_TYPE.UNKNOWN:
if len(bi_list) > 0:
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#continue
if len(bi_list) > 0 and klc.end_klu:
last_bi = bi_list[-1]
#print(klc.start_time, last_bi.start_time, last_bi.end_time, last_bi.dir, last_bi.high, last_bi.low, last_bottom.end_time, "last bi")
if last_top and last_bi.dir == Chan_BI_DIR.DOWN:
if last_bottom and klc.high > last_bi.high:
#print(klc.end_time, "Top 7, 1", last_bi.start_time, klc.high, last_bi.high)
#klc.klc_fx_type = Chan_KLC_FX.TOP7
#klc.fx = Chan_FX_TYPE.TOP
"""
last_bi.set_end_klc(last_bottom, klc)
bi = ChanBI(last_bottom, len(bi_list), Chan_BI_DIR.UP)
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM7)
#klc.bb_out = True
last_bi.set_next(bi)
bi.set_pre(last_bi)
for klc_index in range(last_bi.end_klc.index, len(klc_list)):
bi.add_klc(klc_list[klc_index])
bi_list.append(bi)
last_top = klc
klc.set_bi(bi)
#print(klc.start_time, bi.start_time, bi.end_time, bi.dir, bi.high, bi.low, bi.is_sure)
"""
else:
if last_bottom and last_bi.dir == Chan_BI_DIR.UP:
if last_top and klc.low < last_bi.low:
#print(klc.end_time, "Bottom 8, 2", last_bi.start_time)
#klc.klc_fx_type = Chan_KLC_FX.BOTTOM8
#klc.fx = Chan_FX_TYPE.BOTTOM
"""
last_bi.set_end_klc(last_top, klc)
bi = ChanBI(last_top, len(bi_list), Chan_BI_DIR.DOWN)
#klc.set_klc_fx_type(Chan_KLC_FX.TOP6)
#klc.bb_out = True
last_bi.set_next(bi)
bi.set_pre(last_bi)
for klc_index in range(last_bi.end_klc.index, len(klc_list)):
bi.add_klc(klc_list[klc_index])
bi_list.append(bi)
last_bottom = klc
klc.set_bi(bi)
#print(klc.start_time, bi.start_time, bi.end_time, bi.dir, bi.high, bi.low, bi.is_sure)
"""
else:
last_fx_klc = klc
if fx == Chan_FX_TYPE.TOP:
#print(klc.end_time, fx, klc.pre.high, klc.high, klc.pre.start_time, klc.pre.end_time)
if last_top:
if last_bottom:
#print(klc.start_time, last_bottom.start_time, last_top.start_time)
if last_bottom.index < last_top.index:
# Second top lower to be second sell point
if last_top.high > klc.high:
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#klc.set_klc_fx_type(Chan_KLC_FX.TOP3)
#print(klc.end_time, klc.fx, "二类卖点Sell 1")
else:
# A new top found
#last_top.set_fx(Chan_FX_TYPE.UNKNOWN)
last_top = klc
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 1")
klc.set_klc_fx_type(Chan_KLC_FX.TOP1)
self.check_fx_pattern(klc)
#print(klc.end_time, klc.fx, "一类卖点Sell 1")
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
# 不满足结合律的分型
else:
#klc.set_klc_fx_type(Chan_KLC_FX.TOP0)
#print(klc.end_time, klc.klc_fx_type)
if last_bottom.index + bi_klc_min > klc.index:
if last_top.high > klc.high:
#print(klc.start_time, klc.fx, "二类卖点Sell 1")
#klc.set_klc_fx_type(Chan_KLC_FX.TOP8)
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
# New TOP Found前面的UKNOWN可能出现TOP7,但是这里的也可能出现TOP8分型
else:
# 顶分型在出现2之前超过前一个笔的顶 TOP8
if last_top.index + bi_klc_min < klc.index and len(bi_list) > 1:
pre_last_bi = bi_list[-2]
last_bi = bi_list[-1]
if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.UP and False:
#pre_last_bi.update_bi(klc)
bi_list.remove(last_bi)
pre_last_bi.set_next(None)
#last_top.set_fx(Chan_FX_TYPE.PTOP)
last_top = klc
last_bottom = pre_last_bi.start_klc
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Bottom Change 1")
klc.set_klc_fx_type(Chan_KLC_FX.TOP2)
#print(klc.start_time, last_bi.start_klc.start_time, "New TOP Found reset last bi")
#klc.set_state("10")
#print(klc.start_time, klc.fx, "笔卖点Sell 1")
###klc.set_klc_fx_type(Chan_KLC_FX.TOP2) # when bi is down but the fx is top
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#klc.set_klc_fx_type(Chan_KLC_FX.TOP8)
#print(klc.start_time, last_bi.start_klc.start_time, "New TOP Found reset last bi")
else:
#klc.set_fx(Chan_FX_TYPE.PTOP)
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#print(klc.end_time, klc.fx, "无效顶分型")
# 满足结合律
else:
# New Temp TOP and last bottom confirmed ***** confirm last down bi(last bottom and last top)
last_bi = bi_list[-1]
if not last_bi.is_sure:
last_bi.set_end_klc(last_bottom, klc)
bi = ChanBI(last_bottom, len(bi_list), Chan_BI_DIR.UP)
last_bi.set_next(bi)
bi.set_pre(last_bi)
bi.add_klc(klc)
bi_list.append(bi)
last_top = klc
#print(klc.end_time, klc.fx, bi_list[-1].dir, "Last Top Change 2")
klc.set_klc_fx_type(Chan_KLC_FX.TOP2)
self.check_fx_pattern(klc)
#bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#print(klc.start_time, last_bottom.start_time, "Normal TOP Found, Confirm down bi 4")
# last bottom = None 初始化的时候用,其他时间不用
else:
# 初始化的时候用,其他时间不用
if last_top.high < klc.high:
last_bi = bi_list[-1]
last_bi.set_start_klc(klc, Chan_BI_DIR.DOWN)
last_top = klc
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 3")
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
# 初始化的时候用,其他时间不用
else:
#klc.set_fx(Chan_FX_TYPE.TT)
#print(klc.start_time, klc.fx, "二类卖点Sell 2")
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
# last_top == None 初始化的时候用,其他时间不用
else:
if last_bottom:
# 不满足结合律的分型
if last_bottom.index + bi_klc_min > klc.index:
#klc.set_fx(Chan_FX_TYPE.PTOP)
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#print(klc.start_time, klc.fx, "中枢卖点Sell 1")
else:
# First temp top and last bottom confirmed
last_top = klc
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 4")
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
# Last top = None, last bottom = None, create first down bi 初始化的时候用,其他时间不用
else:
# First temp top
last_top = klc
bi = ChanBI(klc, len(bi_list), Chan_BI_DIR.DOWN)
bi_list.append(bi)
bi.add_klc(klc)
klc.set_bi(bi_list[-1])
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 5")
#klc.fx = Bottom ========================
else:
if last_bottom:
if last_top:
# Bottom after top and find a new bottom
if last_top.index < last_bottom.index:
# Second bottom uppper to be second buy point and confirm last bi
if last_bottom.low < klc.low:
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM3)
#print(last_bottom.start_time, last_bottom.end_time, "--------------------------------1")
#print(klc.end_time, klc.fx, "二类买点Buy 1")
else:
# A new bottom found
last_bottom = klc
#print(klc.end_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 1")
klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM1)
self.check_fx_pattern(klc)
#print(klc.end_time, klc.fx, "一类买点Buy 1")
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
# 不满足结合律的分型
else:
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM0)
#print(klc.end_time, klc.klc_fx_type)
if last_top.index + bi_klc_min > klc.index:
if last_bottom.low < klc.low:
#print(klc.end_time, klc.fx, "中枢买点Buy 1")
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM8)
# Found new bottom没有意义,上面UNKNOWN的时候已经是笔破坏了
else:
#print(klc.end_time, last_bottom.end_time, "Found a new bottom")
if last_bottom.index + bi_klc_min < klc.index and len(bi_list) > 1:
pre_last_bi = bi_list[-2]
last_bi = bi_list[-1]
if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.DOWN and False:
#pre_last_bi.update_bi(klc)
bi_list.remove(last_bi)
pre_last_bi.set_next(None)
last_bottom = klc
last_top = pre_last_bi.start_klc
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Bottom Change 2")
klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2)
#print(klc.start_time, last_bi.start_klc.start_time, "New BOTTOM Found reset last bi")
#print(klc.start_time, klc.fx, "笔买点Buy 1")
###klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2) # when bi is up but the fx is bottom
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM8)
else:
#klc.set_fx(Chan_FX_TYPE.UNKNOWN)
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
print(klc.end_time, klc.fx, "无效底分型")
# 满足结合律的分型
else:
# New Temp Bottom and last top confirmed ***** confirm last up bi(last bottom and last top)
last_bi = bi_list[-1]
if not last_bi.is_sure:
last_bi.set_end_klc(last_top, klc)
bi = ChanBI(last_top, len(bi_list), Chan_BI_DIR.DOWN)
last_bi.set_next(bi)
bi.set_pre(last_bi)
bi.add_klc(klc)
bi_list.append(bi)
last_bottom = klc
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 2")
klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2)
self.check_fx_pattern(klc)
#bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#print(klc.start_time, last_top.start_time, "Normal Bottom Found, Confirm up bi 6")
# last_top = None 初始化的时候用,其他时间不用
else:
if last_bottom.low > klc.low:
last_bi = bi_list[-1]
last_bi.set_start_klc(klc, Chan_BI_DIR.UP)
#last_bottom.set_fx(Chan_FX_TYPE.UNKNOWN)
last_bottom = klc
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 3")
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#print(klc.start_time, klc.fx, "笔买点Buy 3")
else:
#klc.set_fx(Chan_FX_TYPE.BB)
#klc.set_state('-20')
#print(klc.start_time, klc.fx, "二类买点Buy 2")
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
# last_bottom = None 初始化的时候用,其他时间不用
else:
if last_top:
# 不满足结合律的分型
if last_top.index + bi_klc_min > klc.index:
#klc.set_fx(Chan_FX_TYPE.PBOTTOM)
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#print(klc.start_time, klc.fx, "中枢买点Buy 1")
else:
# First temp bottom and last top confirmed
last_bottom = klc
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 4")
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#print(klc.start_time, klc.fx, "一类买点Buy 1")
# Last top = None, last bottom = None, create first up bi
else:
# First temp bottom and no top yet
last_bottom = klc
bi = ChanBI(klc, len(bi_list), Chan_BI_DIR.UP)
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM7)
bi_list.append(bi)
bi_list[-1].add_klc(klc)
klc.set_bi(bi_list[-1])
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 5")
#print(klc.start_time, klc.fx, "笔买点Buy 4")
self.get_above_zero_bsp(klc_list)
#print(bi_list[-1].start_time, bi_list[-1].end_time, len(bi_list[-1].klc_list))
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):
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):
return False
return True
# 线段内的中枢
+410
View File
@@ -0,0 +1,410 @@
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
from __future__ import annotations
from datetime import timedelta
from decimal import Decimal
import numpy as np
import pandas as pd
from pandas import DataFrame
from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS
from chanlun.core.ChanBSP import ChanBSP
from chanlun.core.ChanEnum import (
Chan_BI_DIR,
Chan_BSP_DIR,
Chan_BSP_TYPE,
Chan_FX_TYPE,
Chan_K_DIR,
Chan_KLC_FX,
Chan_KLC_STATE,
Chan_KLINE_DIR,
Chan_KLU_PATTERN,
Chan_PRICE_TREND,
Chan_SEG_DIR,
Chan_ZS_DIR,
)
from chanlun.core.ChanKLC import ChanKLC
from chanlun.core.ChanKLU import ChanKLU
from chanlun.core.ChanSBI import ChanSBI
from chanlun.core.ChanSEG import ChanSEG
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
from chanlun.indicators.ChanMACD import ChanMACD
class BspBuilderMixin:
def get_bsp_state(self, dataframe):
klu_list = self.get_klu_list(dataframe)
klc_list = self.get_klc_list(klu_list)
bi_list = self.cal_bi_list(klc_list)
seg_list = self.get_seg_list(bi_list)
bi_zs_list = self.cal_bi_zs(seg_list)
bsp_list = self.find_all_bsp(bi_list, bi_zs_list)
bsp_state_list = [0] * len(dataframe)
klc_index = 0
for index in range(0, len(dataframe)):
if klc_index == len(klc_list):
klc_index = len(klc_list) - 1
klc = klc_list[klc_index]
if klc.end_klu and klc.end_klu.idx == index:
if klc.klc_fx_type == Chan_KLC_FX.TOP2:
bi = klc.bi.pre
if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.B3:
# 第三类买点
bsp_state_list[index] = -1
#print(klc.end_time, "B3")
else:
bsp_state_list[index] = 0
elif klc.klc_fx_type == Chan_KLC_FX.BOTTOM2:
bi = klc.bi.pre
if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.S3:
# 第三类卖点
bsp_state_list[index] = 1
#print(klc.end_time, "S3")
else:
bsp_state_list[index] = 0
klc_index += 1
else:
bsp_state_list[index] = 0
return bsp_state_list
def get_above_zero_bsp(self, klc_list):
buy_bsp_list = []
sell_bsp_list = []
above_zero = False
buy_bsp = None
sell_bsp = None
for klc in klc_list:
if klc.pre and klc.pre.signal < 0 and klc.signal > 0:
above_zero = True
if klc.pre and klc.pre.signal > 0 and klc.signal < 0:
above_zero = False
if above_zero and klc.klc_fx_type == Chan_KLC_FX.BOTTOM2 and klc.macd > 0:
buy_bsp = klc
buy_bsp_list.append(klc)
#print(klc.end_time, "MACD 0轴上穿,回调笔底分型做多")
if buy_bsp and klc.pre and klc.pre.macdhist > 0 and klc.macdhist < 0:
sell_bsp = klc
sell_bsp_list.append(klc)
buy_bsp = None
#print(klc.end_time, "Sell BSP Found")
return buy_bsp_list
def find_all_bsp(self, bi_list, bi_zs_list):
"""
笔中枢的三类买卖点识别
三类买点中枢形成后一笔向上离开中枢低点 > zg
随后回拉的一笔低点不跌回中枢低点 >= zg确认支撑有效
三类卖点中枢形成后一笔向下离开中枢高点 < zd
随后反弹的一笔高点不回到中枢高点 <= zd确认压力有效
参数:
bi_list: 笔列表
bi_zs_list: 笔中枢列表二维列表每个seg内的中枢列表
返回:
bsp_list: ChanBSP 列表包含所有识别到的三类买卖点
"""
bsp_list = []
if len(bi_list) < 4 or len(bi_zs_list) == 0:
return bsp_list
for zs in bi_zs_list:
if not zs.is_sure or len(zs.bi_list) < 3:
continue
#print(zs.start_time, zs.end_time, zs.dir, zs.is_sure, len(zs.bi_list))
# 中枢结束后的第一笔(离开笔)
last_zs_bi = zs.bi_list[-1]
if last_zs_bi.dir == Chan_BI_DIR.UP:
if last_zs_bi.is_sure and last_zs_bi.end_klc.high <= zs.zg or (last_zs_bi.next and last_zs_bi.next.is_sure and last_zs_bi.next.end_klc.low < zs.zd):
leave_bi = last_zs_bi.next
else:
leave_bi = last_zs_bi
else:
if last_zs_bi.is_sure and last_zs_bi.end_klc.low >= zs.zd or (last_zs_bi.next and last_zs_bi.next.is_sure and last_zs_bi.next.end_klc.high > zs.zg):
leave_bi = last_zs_bi.next
else:
leave_bi = last_zs_bi
#print(zs.zg, zs.zd)
if leave_bi is None or not leave_bi.is_sure:
continue
if (zs.dir == Chan_ZS_DIR.UP and leave_bi.dir == Chan_BI_DIR.UP and leave_bi.end_klc.high < zs.zg and leave_bi.end_klc.high > zs.zd) or (zs.dir == Chan_ZS_DIR.DOWN and leave_bi.dir == Chan_BI_DIR.DOWN and leave_bi.end_klc.low < zs.zg and leave_bi.end_klc.low > zs.zd):
#print("--------------------", leave_bi.dir, leave_bi.end_klc.high, leave_bi.end_klc.low, zs.zg, zs.zd)
leave_bi = leave_bi.next
# 三类买点:向上离开中枢后回拉不破 zg
#print("Leave bi:", leave_bi.start_time, leave_bi.end_time, leave_bi.dir, leave_bi.is_sure, leave_bi.low, leave_bi.high)
if leave_bi.dir == Chan_BI_DIR.UP:
first_bsp_bi_div = self.check_bi_div(zs, leave_bi)
# 确认一类卖点:离开断能量小于进入段能量
if first_bsp_bi_div:
bsp = ChanBSP(
leave_bi, len(bsp_list),
Chan_BSP_TYPE.S1,
Chan_BSP_DIR.SELL,
leave_bi.sure_time,
zs.index+1, zs, None
)
leave_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.S1)
bsp_list.append(bsp)
# 回拉笔
pullback_bi = leave_bi.next
#print(pullback_bi.start_klc.start_time, pullback_bi.dir, pullback_bi.is_sure, pullback_bi.low, pullback_bi.high)
if pullback_bi and pullback_bi.is_sure and pullback_bi.dir == Chan_BI_DIR.DOWN:
if pullback_bi.low >= zs.zg:
# 确认三类买点:回拉笔的低点不跌回中枢
bsp = ChanBSP(
pullback_bi, len(bsp_list),
Chan_BSP_TYPE.B3,
Chan_BSP_DIR.BUY,
pullback_bi.sure_time,
zs.index+1, zs, None
)
pullback_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B3)
bsp_list.append(bsp)
# 二类卖点
if first_bsp_bi_div:
second_bsp_bi = pullback_bi.next
if second_bsp_bi and second_bsp_bi.is_sure and second_bsp_bi.end_klc.high < leave_bi.end_klc.high:
# 确认二类卖点:一类卖点后回拉不超过一类卖点高点
bsp = ChanBSP(
second_bsp_bi, len(bsp_list),
Chan_BSP_TYPE.S2,
Chan_BSP_DIR.SELL,
second_bsp_bi.sure_time,
zs.index+1, zs, None
)
second_bsp_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B2)
bsp_list.append(bsp)
# 三类卖点:向下离开中枢后反弹不破 zd
elif leave_bi.dir == Chan_BI_DIR.DOWN:
first_bsp_bi_div = self.check_bi_div(zs, leave_bi)
# 确认一类买点:离开段能量小于进入段
if first_bsp_bi_div:
bsp = ChanBSP(
leave_bi, len(bsp_list),
Chan_BSP_TYPE.B1,
Chan_BSP_DIR.BUY,
leave_bi.sure_time,
zs.index+1, zs, None
)
leave_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B1)
bsp_list.append(bsp)
# 反弹笔
bounce_bi = leave_bi.next
#print(bounce_bi.start_klc.start_time, bounce_bi.dir, bounce_bi.is_sure, bounce_bi.low, bounce_bi.high)
if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP:
if bounce_bi.high <= zs.zd:
# 确认三类卖点:反弹笔的高点不回到中枢
bsp = ChanBSP(
bounce_bi, len(bsp_list),
Chan_BSP_TYPE.S3,
Chan_BSP_DIR.SELL,
bounce_bi.sure_time,
zs.index+1, zs, None
)
bounce_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.S3)
bsp_list.append(bsp)
# 二类卖点
if first_bsp_bi_div:
second_bsp_bi = bounce_bi.next
if second_bsp_bi and second_bsp_bi.is_sure and second_bsp_bi.end_klc.low > leave_bi.end_klc.low:
# 确认二类买点:一类买点后回拉不超过一类卖点高点
bsp = ChanBSP(
second_bsp_bi, len(bsp_list),
Chan_BSP_TYPE.B2,
Chan_BSP_DIR.BUY,
second_bsp_bi.sure_time,
zs.index+1, zs, None
)
second_bsp_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B2)
bsp_list.append(bsp)
return bsp_list
def check_bi_div(self, zs, leave_bi):
enter_bi = zs.bi_list[0].pre
macdhist_div = 0
if enter_bi and enter_bi.dir == leave_bi.dir:
macdhist_div = abs(leave_bi.macd_hist) - abs(enter_bi.macd_hist)
#print(enter_bi.end_time, leave_bi.end_time, macdhist_div < 0)
return macdhist_div < 0
def find_first_bsp(self, bi_list, bi_zs_list):
"""
笔中枢的一类买卖点识别
一类买点下跌趋势中最后一个中枢完成后向下离开中枢的笔创新低
但该笔与进入中枢前的最后一笔下跌形成底背驰力度减弱
即趋势力竭的转折点
一类卖点上涨趋势中最后一个中枢完成后向上离开中枢的笔创新高
但该笔与进入中枢前的最后一笔上涨形成顶背驰力度减弱
即趋势力竭的转折点
简化判断中枢形成后离开中枢的笔突破笔本身即为一类买卖点的触发笔
参数:
bi_list: 笔列表
bi_zs_list: 笔中枢列表扁平列表每个元素是一个中枢对象
返回:
bsp_list: ChanBSP 列表包含所有识别到的一类买卖点
"""
bsp_list = []
if len(bi_list) < 4 or len(bi_zs_list) == 0:
return bsp_list
for zs in bi_zs_list:
if not zs.is_sure or len(zs.bi_list) < 3:
continue
# 找到中枢的最后一笔
last_zs_bi = zs.bi_list[-1]
# 确定离开笔:中枢最后一笔之后的第一笔
if last_zs_bi.dir == Chan_BI_DIR.UP:
# 中枢最后一笔向上,如果没有真正离开中枢,取下一笔
if last_zs_bi.is_sure and last_zs_bi.end_klc.high <= zs.zg:
leave_bi = last_zs_bi.next
else:
leave_bi = last_zs_bi
else:
# 中枢最后一笔向下,如果没有真正离开中枢,取下一笔
if last_zs_bi.is_sure and last_zs_bi.end_klc.low >= zs.zd:
leave_bi = last_zs_bi.next
else:
leave_bi = last_zs_bi
if leave_bi is None or not leave_bi.is_sure:
continue
# 一类买点:向下离开中枢(leave_bi向下,低点 < zd),趋势力竭
if leave_bi.dir == Chan_BI_DIR.DOWN and leave_bi.low < zs.zd:
# 背驰判断:比较离开笔与中枢内最后一笔同向笔的MACD柱状累积面积
# 缠论原文:两段同向走势的MACD柱状面积比较,面积缩小即为背驰
compare_bi = None
for bi in reversed(zs.bi_list):
if bi.dir == Chan_BI_DIR.DOWN and bi is not leave_bi:
compare_bi = bi
break
is_divergence = False
if compare_bi:
# 笔的macd_hist是该笔内所有KLU的macdhist累积面积
leave_macd_area = abs(leave_bi.macd_hist)
compare_macd_area = abs(compare_bi.macd_hist)
# 价格创新低但MACD面积缩小 = 底背驰
if leave_bi.low <= compare_bi.low and leave_macd_area < compare_macd_area:
is_divergence = True
# 即使没创新低,MACD面积明显缩小也算背驰
elif leave_macd_area < compare_macd_area * 0.5:
is_divergence = True
else:
# 没有对比笔时,只要离开中枢就算一类买点
is_divergence = True
if is_divergence:
bsp = ChanBSP(
leave_bi, len(bsp_list),
Chan_BSP_TYPE.T1,
Chan_BSP_DIR.BUY,
leave_bi.sure_time,
1, zs, None
)
bsp_list.append(bsp)
# 一类卖点:向上离开中枢(leave_bi向上,高点 > zg),趋势力竭
elif leave_bi.dir == Chan_BI_DIR.UP and leave_bi.high > zs.zg:
# 背驰判断:比较离开笔与中枢内最后一笔同向笔的MACD柱状累积面积
compare_bi = None
for bi in reversed(zs.bi_list):
if bi.dir == Chan_BI_DIR.UP and bi is not leave_bi:
compare_bi = bi
break
is_divergence = False
if compare_bi:
leave_macd_area = abs(leave_bi.macd_hist)
compare_macd_area = abs(compare_bi.macd_hist)
# 价格创新高但MACD面积缩小 = 顶背驰
if leave_bi.high >= compare_bi.high and leave_macd_area < compare_macd_area:
is_divergence = True
# 即使没创新高,MACD面积明显缩小也算背驰
elif leave_macd_area < compare_macd_area * 0.5:
is_divergence = True
else:
is_divergence = True
if is_divergence:
bsp = ChanBSP(
leave_bi, len(bsp_list),
Chan_BSP_TYPE.T1,
Chan_BSP_DIR.SELL,
leave_bi.sure_time,
1, zs, None
)
bsp_list.append(bsp)
return bsp_list
def find_second_bsp(self, bi_list, first_bsp_list):
"""
笔中枢的二类买卖点识别
二类买点一类买点出现后价格向上反弹一笔再回落一笔
回落笔的低点不跌破一类买点的低点确认底部成立
二类卖点一类卖点出现后价格向下回落一笔再反弹一笔
反弹笔的高点不超过一类卖点的高点确认顶部成立
参数:
bi_list: 笔列表
first_bsp_list: 一类买卖点列表find_first_bsp 的返回值
返回:
bsp_list: ChanBSP 列表包含所有识别到的二类买卖点
"""
bsp_list = []
if not first_bsp_list or len(bi_list) < 4:
return bsp_list
for first_bsp in first_bsp_list:
trigger_bi = first_bsp.bi # 一类买卖点的触发笔
if first_bsp.dir == Chan_BSP_DIR.BUY:
# 一买之后:trigger_bi 向下 -> 反弹笔(向上) -> 回落笔(向下)
# 回落笔的低点 > trigger_bi 的低点 => 二类买点
bounce_bi = trigger_bi.next # 反弹笔(向上)
if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP:
pullback_bi = bounce_bi.next # 回落笔(向下)
if pullback_bi and pullback_bi.is_sure and pullback_bi.dir == Chan_BI_DIR.DOWN:
if pullback_bi.low > trigger_bi.low:
bsp = ChanBSP(
pullback_bi, len(bsp_list),
Chan_BSP_TYPE.T2,
Chan_BSP_DIR.BUY,
pullback_bi.sure_time,
1, first_bsp.zs, None
)
bsp_list.append(bsp)
elif first_bsp.dir == Chan_BSP_DIR.SELL:
# 一卖之后:trigger_bi 向上 -> 回落笔(向下) -> 反弹笔(向上)
# 反弹笔的高点 < trigger_bi 的高点 => 二类卖点
drop_bi = trigger_bi.next # 回落笔(向下)
if drop_bi and drop_bi.is_sure and drop_bi.dir == Chan_BI_DIR.DOWN:
bounce_bi = drop_bi.next # 反弹笔(向上)
if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP:
if bounce_bi.high < trigger_bi.high:
bsp = ChanBSP(
bounce_bi, len(bsp_list),
Chan_BSP_TYPE.T2,
Chan_BSP_DIR.SELL,
bounce_bi.sure_time,
1, first_bsp.zs, None
)
bsp_list.append(bsp)
return bsp_list
+148
View File
@@ -0,0 +1,148 @@
"""第四类买卖点(B4/S4)接入 TF_DF。
判定逻辑全在 chanlun/analysis/fast_bsp.py这里只负责把引擎的中枢/K线喂进去
再把结果包成 ChanFastBSP
刻意不在 init_TF_DF 里默认计算现有构造路径的开销保持不变由调用方按需触发
"""
from __future__ import annotations
import re
import pandas as pd
from chanlun.analysis.fast_bsp import (
add_zone_ladder,
attach_htf_agree,
attach_zone_ladder,
ensure_timestamp,
find_fast_bsp3,
htf_fx_timeline,
zones_from_zs_list,
)
from chanlun.core.ChanEnum import Chan_BSP_DIR
from chanlun.core.ChanFastBSP import ChanFastBSP
# 区间套配对:小级别出中枢与买卖点,大级别只出分型定方向。
# 取值来自 research/HANDOFF.md §1.5,是回测里实际用过的组合。
FAST_BSP_HTF_PAIR = {
'1m': '5m',
'5m': '30m',
'15m': '1h',
'30m': '2h',
}
# 未列入配对表的周期回落到这个倍数
FAST_BSP_HTF_FALLBACK_RATIO = 4
_TF_UNIT_MINUTES = {'m': 1, 'h': 60, 'd': 1440, 'w': 10080}
def timeframe_minutes(tf: str) -> int | None:
"""'30m' -> 30'2h' -> 120。无法解析时返回 None。"""
if not tf:
return None
m = re.fullmatch(r'(\d+)\s*([mhdw])', str(tf).strip().lower())
if not m:
return None
return int(m.group(1)) * _TF_UNIT_MINUTES[m.group(2)]
def resolve_htf(tf: str) -> tuple[str, int] | None:
"""给小级别找配套的大级别,返回 (标签, 分钟数)。"""
minutes = timeframe_minutes(tf)
if minutes is None:
return None
paired = FAST_BSP_HTF_PAIR.get(str(tf).strip().lower())
if paired:
return paired, timeframe_minutes(paired)
return f'{minutes * FAST_BSP_HTF_FALLBACK_RATIO}m', minutes * FAST_BSP_HTF_FALLBACK_RATIO
class FastBspBuilderMixin:
def build_fast_bsp_htf(self, df, timeframe=None):
"""对同一份 df 重采样得到大级别,不额外拉数据。
大级别只用来取分型方向样本太少就没有过滤意义故重采样后不足 60 根时放弃
"""
tf = timeframe or getattr(self, 'timeframe', None)
htf = resolve_htf(tf)
ltf_minutes = timeframe_minutes(tf)
if htf is None or not ltf_minutes:
return None
label, minutes = htf
if not minutes or len(df) * ltf_minutes < minutes * 60:
return None
try:
from chanlun.pipeline.timeframe import TF_DF
return TF_DF(df, minutes, label)
except Exception:
return None
def cal_fast_bsp(self, df=None, bi_zs_list=None, htf_chan=None, with_htf=True,
timeframe=None, **kw):
"""算第四类买卖点,返回 ChanFastBSP 列表。
bi_zs_list 传入已算好的 pure 笔中枢可免去重复计算
with_htf=False 时跳过大级别构建只留 ladder_ok 这一个过滤标志
kw 透传给 find_fast_bsp3scan / pullback_win / tol / require_touch
"""
src = df if df is not None else getattr(self, 'dataframe', None)
if src is None or len(src) == 0:
self.fast_bsp_list = []
return self.fast_bsp_list
src = ensure_timestamp(src)
if bi_zs_list is None:
bi_zs_list = getattr(self, 'bi_zs_list', None)
if not bi_zs_list:
bi_zs_list = self.cal_bi_zs_list_pure(self.cal_bi_list(self.get_klc_list(self.cal_kl_data(src))))
zones = zones_from_zs_list(bi_zs_list, src)
if zones.empty:
self.fast_bsp_list = []
return self.fast_bsp_list
zones = add_zone_ladder(zones)
sig = find_fast_bsp3(src, zones, **kw)
if sig.empty:
self.fast_bsp_list = []
return self.fast_bsp_list
sig = attach_zone_ladder(sig, zones)
if with_htf:
if htf_chan is None:
htf_chan = self.build_fast_bsp_htf(src, timeframe)
sig = attach_htf_agree(sig, src, htf_fx_timeline(htf_chan) if htf_chan is not None else pd.DataFrame())
else:
sig['htf_dir'] = None
sig['htf_agree'] = None
times = src['date'].dt.strftime('%Y-%m-%d %H:%M:%S').to_numpy()
close = src['close'].to_numpy(dtype=float)
out = []
for r in sig.itertuples(index=False):
entry_idx = int(r.entry_idx)
agree = getattr(r, 'htf_agree', None)
htf_dir = getattr(r, 'htf_dir', None)
out.append(ChanFastBSP(
time=times[entry_idx],
price=close[entry_idx],
ddir=Chan_BSP_DIR.BUY if r.direction == 1 else Chan_BSP_DIR.SELL,
entry_idx=entry_idx,
bo_time=times[int(r.bo_idx)],
pb_time=times[int(r.pb_idx)] if r.pb_idx == r.pb_idx else None,
lag=r.lag,
depth=r.depth,
zg=r.zg,
zd=r.zd,
occ=r.occ,
htf_dir=None if htf_dir is None or htf_dir != htf_dir else int(htf_dir),
htf_agree=None if agree is None or agree != agree else bool(agree),
ladder_ok=bool(r.ladder_ok),
))
self.fast_bsp_list = out
return out
+171
View File
@@ -0,0 +1,171 @@
"""增量更新:新K只追加 KLU/KLC,笔与笔中枢在当前列表上重算。
不改 init_TF_DF 的整段语义笔必须整表重扫最后一笔 is_sure 允许收回
OWN_CHAN_ZS_001 60 天出现 7 笔中枢用 cal_bi_zs_list_pure
"""
from __future__ import annotations
from datetime import datetime
import pandas as pd
from pandas import DataFrame
from chanlun.pipeline.resample import resample_to_interval
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_KLC_STATE
from chanlun.core.ChanKLU import ChanKLU
class IncrementalBuilderMixin:
def init_stream(self, df, interval=1, timeframe=None):
"""用历史K线初始化流式状态,之后用 append_bar / replace_last_bar。"""
if df is None or df.empty:
raise ValueError("DataFrame for stream is empty.")
if "date" not in df.columns:
raise ValueError(f"DataFrame missing 'date' column. Columns: {df.columns.tolist()}")
self.timeframe = timeframe
self.interval = interval
if interval == 1:
self.dataframe = df.copy()
else:
self.dataframe = resample_to_interval(df, interval)
self.dataframe = self.add_indicators(self.dataframe)
self.klu_list = []
self.klc_list = []
self.bi_list = []
self.bi_zs_list = []
self.seg_list = []
self.zs_list = []
self.bsp_list = []
self.klc_fx_list = []
self.big_zs_list = []
self._klc_feed_last_klu = None
for i in range(len(self.dataframe)):
self._append_row_at(i, rebuild=False)
self.rebuild_bi_zs()
return self
def append_bar(self, row):
"""追加一根已收盘K线。同一时间戳则改为替换最后一根。"""
self._ensure_stream_state()
item = self._normalize_row(row)
if self.klu_list and self.klu_list[-1].time == self._row_time_str(item):
return self.replace_last_bar(item)
self._append_item_to_dataframe(item)
self.dataframe = self.add_indicators(self.dataframe)
self._append_row_at(len(self.dataframe) - 1, rebuild=True)
return self
def replace_last_bar(self, row):
"""更新最后一根K(未完成K线走新OHLC)。包含关系从 KLU 列表重放。"""
self._ensure_stream_state()
if not self.klu_list:
return self.append_bar(row)
item = self._normalize_row(row)
idx = self.dataframe.index[-1]
for key, val in item.items():
self.dataframe.at[idx, key] = val
self.dataframe = self.add_indicators(self.dataframe)
self._apply_item_to_klu(self.klu_list[-1], self.dataframe.iloc[-1])
self._rebuild_klc_from_klu()
self.rebuild_bi_zs()
return self
def rebuild_bi_zs(self):
"""在当前 KLC 上重算笔 + cal_bi_zs_list_pure。会先清分型标记。"""
self._reset_klc_bi_marks(self.klc_list)
self.bi_list = self.cal_bi_list(self.klc_list) if self.klc_list else []
self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list) if self.bi_list else []
return self.bi_zs_list
def _ensure_stream_state(self):
if not hasattr(self, "klu_list") or self.klu_list is None:
self.klu_list = []
if not hasattr(self, "klc_list") or self.klc_list is None:
self.klc_list = []
if not hasattr(self, "dataframe") or self.dataframe is None:
self.dataframe = DataFrame(
columns=["date", "open", "high", "low", "close", "volume"]
)
if not hasattr(self, "_klc_feed_last_klu"):
self._klc_feed_last_klu = self.klu_list[-1] if self.klu_list else None
if not hasattr(self, "bi_zs_list"):
self.bi_zs_list = []
def _rebuild_klc_from_klu(self):
self.klc_list = []
last_klu = None
for klu in self.klu_list:
self._push_klu_into_klc_list(self.klc_list, klu, last_klu)
last_klu = klu
self._klc_feed_last_klu = last_klu
def _append_row_at(self, idx, rebuild=True):
item = self.dataframe.iloc[idx]
klu = self._klu_from_item(item, idx)
if self.klu_list:
self.klu_list[-1].set_next(klu)
klu.set_pre(self.klu_list[-1])
self._push_klu_into_klc_list(self.klc_list, klu, self._klc_feed_last_klu)
self._klc_feed_last_klu = klu
self.klu_list.append(klu)
if rebuild:
self.rebuild_bi_zs()
def _klu_from_item(self, item, idx):
klu = ChanKLU(
self._item_time_str(item),
item["open"],
item["high"],
item["low"],
item["close"],
item["volume"],
)
klu.set_idx(idx)
if not hasattr(klu, "ema13"):
klu.ema13 = 0
if "macd" in item:
klu.set_indicators(item)
return klu
def _apply_item_to_klu(self, klu, item):
klu.time = self._item_time_str(item)
klu.open = item["open"]
klu.high = item["high"]
klu.low = item["low"]
klu.close = item["close"]
klu.volume = item["volume"]
klu.range = klu.high - klu.low
klu.body = abs(klu.close - klu.open)
if "macd" in item:
klu.set_indicators(item)
def _reset_klc_bi_marks(self, klc_list):
for klc in klc_list:
klc.fx = Chan_FX_TYPE.UNKNOWN
klc.klc_fx_type = Chan_KLC_FX.UNKNOWN
klc.klc_state = Chan_KLC_STATE.UNKNOWN
klc.bi = None
klc.fx_confirmed = False
def _item_time_str(self, item):
date = item["date"]
if hasattr(date, "to_pydatetime"):
date = date.to_pydatetime()
if isinstance(date, datetime):
return date.strftime("%Y-%m-%d %H:%M:%S")
return str(date)
def _row_time_str(self, item):
return self._item_time_str(item)
def _normalize_row(self, row):
if isinstance(row, pd.Series):
return row
return pd.Series(row)
def _append_item_to_dataframe(self, item):
row_df = DataFrame([item])
if self.dataframe is None or self.dataframe.empty:
self.dataframe = row_df
else:
self.dataframe = pd.concat([self.dataframe, row_df], ignore_index=True)
+141
View File
@@ -0,0 +1,141 @@
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
from __future__ import annotations
from datetime import timedelta
from decimal import Decimal
import numpy as np
import pandas as pd
from chanlun.indicators import ta
from pandas import DataFrame
from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS
from chanlun.core.ChanBSP import ChanBSP
from chanlun.core.ChanEnum import (
Chan_BI_DIR,
Chan_BSP_DIR,
Chan_BSP_TYPE,
Chan_FX_TYPE,
Chan_K_DIR,
Chan_KLC_FX,
Chan_KLC_STATE,
Chan_KLINE_DIR,
Chan_KLU_PATTERN,
Chan_PRICE_TREND,
Chan_SEG_DIR,
Chan_ZS_DIR,
)
from chanlun.core.ChanKLC import ChanKLC
from chanlun.core.ChanKLU import ChanKLU
from chanlun.core.ChanSBI import ChanSBI
from chanlun.core.ChanSEG import ChanSEG
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
from chanlun.indicators.ChanMACD import ChanMACD
class IndicatorsBuilderMixin:
def get_ema52(self, index=-1):
if self.klu_list:
ema52_value = self.klu_list[index].ema52
# 处理NaN值
if pd.isna(ema52_value) or ema52_value is None:
return None
return float(ema52_value)
return None
def get_ema24(self, index=-1):
if self.klu_list:
ema24_value = self.klu_list[index].ema24
# 处理NaN值
if pd.isna(ema24_value) or ema24_value is None:
return None
return float(ema24_value)
return None
def add_indicators(self, df):
"""算指标并一次性挂到 df 上。
这里不逐列 `df['x'] = ...`那样每一列都触发一次 BlockManager 插入
30 多列的开销比全部 TA 计算本身还大2001 行实测 TA 合计 2.5ms
逐列赋值 3.6ms增量路径每根都要走一遍这笔开销是白付的
"""
fast = 26
slow = 52
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)
bb120 = ta.BBANDS(df, timeperiod=120, nbdevup=3.0, nbdevdn=3.0, matype=0)
bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0)
bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0)
bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
bb2633 = ta.BBANDS(df, timeperiod=26, nbdevup=3.0, nbdevdn=3.0, matype=0)
# 计算布林带中轨(移动平均线)
bb30_middle = ta.SMA(df, timeperiod=90)
# 手动计算布林带 %B 指标 (BBP)
# %B = (Price - Lower Band) / (Upper Band - Lower Band)
bbp365 = (df['close'] - bb365['lowerband']) / (bb365['upperband'] - bb365['lowerband'])
bbp120 = (df['close'] - bb120['lowerband']) / (bb120['upperband'] - bb120['lowerband'])
bbp30 = (df['close'] - bb30['lowerband']) / (bb30['upperband'] - bb30['lowerband'])
bbp302 = (df['close'] - bb302['lowerband']) / (bb302['upperband'] - bb302['lowerband'])
bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband'])
cols = {
'bb2633upper': bb2633['upperband'],
'bb2633lower': bb2633['lowerband'],
'bbp2633': bbp2633,
'bb2633middle': bb2633['middleband'],
'atr': ta.ATR(df, timeperiod=14),
'bbup365': bb365['upperband'],
'bblow365': bb365['lowerband'],
'bbp365': bbp365,
'bbup120': bb120['upperband'],
'bblow120': bb120['lowerband'],
'bbp120': bbp120,
'bbup30': bb30['upperband'],
'bblow30': bb30['lowerband'],
'bbmiddle30': bb30_middle,
'bbp30': bbp30,
'bbup302': bb302['upperband'],
'bblow302': bb302['lowerband'],
'bbp302': bbp302,
'macd': macd['macd'],
'macdsignal': macd['macdsignal'],
'macdhist': macd['macdhist'],
}
for _p, _n in ((5, 'ema5'), (10, 'ema10'), (24, 'ema24'), (52, 'ema52'),
(104, 'ema104'), (156, 'ema156'), (208, 'ema208'),
(26, 'ema26'), (13, 'ema13'), (7, 'ema7')):
cols[_n] = ta.EMA(df, timeperiod=_p)
cols['rsi'] = ta.RSI(df, timeperiod=14)
cols['volume_ratio'] = self.cal_volume_ratio(df)
# 重复调用(增量路径每根都会)时先摘掉旧列,否则 concat 出重名列。
# 摘掉再接回末尾,列序与逐列覆盖的结果一致。
new = pd.DataFrame(cols, index=df.index)
dup = [c for c in new.columns if c in df.columns]
if dup:
df = df.drop(columns=dup)
return pd.concat([df, new], axis=1)
def get_ema_state(self, dataframe):
klu_list = self.get_klu_list(dataframe)
klc_list = self.get_klc_list(klu_list)
bi_list = self.cal_bi_list(klc_list)
klu_state_list = []
for klu in klu_list:
if klu.near0_return == 1:
klu_state_list.append("1")
elif klu.near0_return == 9:
klu_state_list.append("-1")
elif klu.candle_dir == Chan_K_DIR.BULL:
klu_state_list.append("2")
elif klu.candle_dir == Chan_K_DIR.BEAR:
klu_state_list.append("-2")
else:
klu_state_list.append("0")
return klu_state_list
def get_decimal(self, value):
return Decimal("{:.2f}".format(value))
+573
View File
@@ -0,0 +1,573 @@
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
from __future__ import annotations
from datetime import timedelta
from decimal import Decimal
import numpy as np
import pandas as pd
from pandas import DataFrame
from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS
from chanlun.core.ChanBSP import ChanBSP
from chanlun.core.ChanEnum import (
Chan_BI_DIR,
Chan_BSP_DIR,
Chan_BSP_TYPE,
Chan_FX_TYPE,
Chan_K_DIR,
Chan_KLC_FX,
Chan_KLC_STATE,
Chan_KLINE_DIR,
Chan_KLU_PATTERN,
Chan_PRICE_TREND,
Chan_SEG_DIR,
Chan_ZS_DIR,
)
from chanlun.core.ChanKLC import ChanKLC
from chanlun.core.ChanKLU import ChanKLU
from chanlun.core.ChanSBI import ChanSBI
from chanlun.core.ChanSEG import ChanSEG
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
from chanlun.indicators.ChanMACD import ChanMACD
class KlineBuilderMixin:
def get_klu_state(self, dataframe):
klc_list = self.get_klc_list(self.get_klu_list(dataframe))
bi_list = self.cal_bi_list(klc_list)
klu_state_list = []
klc_index = 0
for index in range(0, len(dataframe)):
if klc_index == len(klc_list):
klc_index = len(klc_list) - 1
klc = klc_list[klc_index]
if klc.end_klu and klc.end_klu.idx == index:
if klc.klc_state == Chan_KLC_STATE.S10:
klu_state_list.append("10")
#print(klc.end_time, klc.klc_fx_type)
elif klc.klc_state == Chan_KLC_STATE.S_10:
klu_state_list.append("-10")
#print(klc.end_time, klc.klc_fx_type)
elif klc.klc_state == Chan_KLC_STATE.S11:
klu_state_list.append("11")
#print(klc.end_time, klc.klc_fx_type)
elif klc.klc_state == Chan_KLC_STATE.S_11:
klu_state_list.append("-11")
#print(klc.end_time, klc.klc_fx_type)
else:
klu_state_list.append("00")
klc_index += 1
else:
klu_state_list.append("00")
print(klu_state_list[:20])
return klu_state_list
def check_fx1(self, klc):
if klc.pre and klc.next:
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low:
if klc.pre.pre and klc.next.next:
if klc.high > klc.pre.pre.high and klc.high > klc.next.next.high:
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
klc.set_fx(Chan_FX_TYPE.TOP)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP")
return Chan_FX_TYPE.TOP
elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high:
#if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0:
if klc.pre.pre and klc.next.next:
if klc.low < klc.pre.pre.low and klc.low < klc.next.next.low:
klc.set_fx(Chan_FX_TYPE.BOTTOM)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM")
return Chan_FX_TYPE.BOTTOM
return Chan_FX_TYPE.UNKNOWN
def check_fx(self, klc):
# 右K未完成(仍在包含合并)时不分型:否则确认笔会随 next 扩区间被 check_*_fx 收回
if klc.pre and klc.next and klc.next.end_klu is not None:
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low:
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
klc.set_fx(Chan_FX_TYPE.TOP)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP")
return Chan_FX_TYPE.TOP
elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high:
#if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0:
klc.set_fx(Chan_FX_TYPE.BOTTOM)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM")
return Chan_FX_TYPE.BOTTOM
return Chan_FX_TYPE.UNKNOWN
def check_fx3(self, klc):
# 右K未完成(仍在包含合并)时不分型:否则确认笔会随 next 扩区间被 check_*_fx 收回
if klc.pre and klc.next and klc.next.end_klu is not None:
next_klu = klc.next.end_klu.next
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low and klc.high > next_klu.high:
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
klc.set_fx(Chan_FX_TYPE.TOP)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP")
return Chan_FX_TYPE.TOP
elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high and klc.low < next_klu.low:
#if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0:
klc.set_fx(Chan_FX_TYPE.BOTTOM)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM")
return Chan_FX_TYPE.BOTTOM
return Chan_FX_TYPE.UNKNOWN
def check_fx2(self, klc):
if klc.pre and klc.next:
if klc.high > klc.pre.close and klc.close > klc.next.close and klc.close > klc.pre.close and klc.close > klc.next.close:
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
klc.set_fx(Chan_FX_TYPE.TOP)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP")
return Chan_FX_TYPE.TOP
elif klc.low < klc.pre.close and klc.close < klc.next.close and klc.close < klc.pre.close and klc.close < klc.next.close:
#if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0:
klc.set_fx(Chan_FX_TYPE.BOTTOM)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM")
return Chan_FX_TYPE.BOTTOM
return Chan_FX_TYPE.UNKNOWN
def check_fx_pattern(self, klc):
"""给分型前后三根 KLC 的裸 K 打 pattern 标记。
原本还会把 `klu.to_string()` 拼成一个字符串那是给下面那行注释掉的
print 用的拼完就丢它在 cal_bi_list 的内层2000 根上要跑近三万次
f-string + 六万次 enum 格式化是纯废动作已删
`klu.pattern` 只被 cal_klu_pattern 自己的双 K / K 判定读
不出这个模块也不进 web 序列化所以 lean 下整个调用可跳
"""
if getattr(self, 'lean', False):
return
klu_list = klc.pre.klu_list + klc.klu_list + klc.next.klu_list
self.cal_klu_pattern(klu_list)
def cal_volume_ratio(self, dataframe, window=10):
"""当根量 / 前 window 根均量。前 window-1 根无基准,填 1.0。
原写法先 `dataframe.copy()` 再挂两列为算一列 rolling 复制了整张
四十列的表直接在 Series 上算结果逐值相同
"""
vol = dataframe['volume']
return (vol / vol.rolling(window=window).mean()).fillna(1.0).rename('volume_ratio')
def cal_kl_data(self, dataframe:DataFrame):
"""按行构造 KLU 链。
这里刻意不用 `dataframe.iloc[i]`那会为每一根新建一个几十列的 Series
随后 set_indicators 再在其上做几十次逐键查找实测这两件事合计占 TF_DF
构建耗时的 96%改为先把用到的列取成 ndarray循环里只做整数下标访问
"""
n = len(dataframe)
if n == 0:
return []
times = self._format_times(dataframe['date'])
o_a = dataframe['open'].to_numpy(dtype=float)
h_a = dataframe['high'].to_numpy(dtype=float)
l_a = dataframe['low'].to_numpy(dtype=float)
c_a = dataframe['close'].to_numpy(dtype=float)
v_a = dataframe['volume'].to_numpy(dtype=float)
has_ind = 'macd' in dataframe.columns
ind_cols = {}
if has_ind:
for _attr, col in ChanKLU.INDICATOR_FIELDS:
if col in dataframe.columns:
ind_cols[col] = dataframe[col].to_numpy(dtype=float)
klu_list = []
last_klu = None
for i in range(n):
klu = ChanKLU(times[i], o_a[i], h_a[i], l_a[i], c_a[i], v_a[i])
klu.set_idx(i)
klu_list.append(klu)
if last_klu:
last_klu.set_next(klu)
klu.set_pre(last_klu)
last_klu = klu
if has_ind:
klu.set_indicators_from(ind_cols, i)
return klu_list
@staticmethod
def _format_times(col):
"""向量化 strftime。非 datetime 列(少见)退回逐个格式化。"""
fmt = '%Y-%m-%d %H:%M:%S'
try:
return col.dt.strftime(fmt).to_numpy()
except AttributeError:
return np.array([d.strftime(fmt) for d in col], dtype=object)
def get_kl_data(self, dataframe:DataFrame):
return self.cal_kl_data(dataframe)
def _push_klu_into_klc_list(self, klc_list, klu, last_klu):
"""把一根 KLU 并入包含K线列表。与 get_klc_list 的几何规则相同。"""
if len(klc_list) > 0:
last_klc = klc_list[-1]
if klu.exception:
ddir = Chan_KLINE_DIR.DOWN
if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc.high = klu.close if klu.close > klu.open else klu.open
klc.low = klu.open if klu.close > klu.open else klu.close
klc_list.append(klc)
last_klc.set_next(klc)
klc.set_pre(last_klc)
last_klc.set_end_klu(last_klu)
klc.set_pre_fx()
else:
included = last_klc.check_klu_included(klu)
if not included:
ddir = Chan_KLINE_DIR.DOWN
if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc_list.append(klc)
last_klc.set_next(klc)
klc.set_pre(last_klc)
last_klc.set_end_klu(last_klu)
klc.set_pre_fx()
else:
last_klc.add_klu(klu)
else:
ddir = Chan_KLINE_DIR.UP
if klu.open > klu.close:
ddir = Chan_KLINE_DIR.DOWN
klc = ChanKLC(klu, 0, ddir)
klc_list.append(klc)
def get_klc_list(self, klu_list):
klc_list = []
# ChanMACD.__init__ 已调用 cal_macd_state,切勿再调一次(会重复堆积 seg/unittf)。
# lean 模式跳过整套 MACD 状态机:它只服务于 bsp/背驰/web 展示,笔与中枢不依赖它。
if getattr(self, 'lean', False):
self._last_chan_macd = None
else:
macd = ChanMACD(klu_list)
klu_list = macd.klu_list
self._last_chan_macd = macd
last_klu = None
for klu in klu_list:
self._push_klu_into_klc_list(klc_list, klu, last_klu)
last_klu = klu
# cal_trend 只产出 klc.trend,而全仓只有它自己(经 prev_klcs 读自身序列
# 状态)、一个 __str__ 和 web 的 klc_trend 图层消费它——笔与中枢不读。
# 增量路径的 klc 其 trend 恒为 UNKNOWN 却与批量构建逐字段相同,是实证。
# 所以 lean 下可跳;web 走非 lean,图层不受影响。
if not getattr(self, 'lean', False):
klc_list = self.cal_trend(klc_list)
return klc_list
def get_klu_list(self, dataframe):
klu_list = self.get_kl_data(dataframe)
#klu_list = self.cal_klu_pattern(klu_list)
return klu_list
def cal_klu_pattern(self, klu_list):
"""
计算裸K的pattern - 识别反转形态
"""
if not klu_list or len(klu_list) < 3:
return klu_list
for i, klu in enumerate(klu_list):
# 单根K线反转模式识别
self._detect_single_reversal_pattern(klu)
# 双根K线形态识别
if i >= 1:
self._detect_double_pattern(klu_list[i-1], klu)
# 三根K线形态识别
if i >= 2:
self._detect_triple_pattern(klu_list[i-2], klu_list[i-1], klu)
#if klu.pattern != Chan_KLU_PATTERN.UNKNOWN:
#print(klu.time, klu.pattern, klu.lower_shadow_ratio, klu.upper_shadow_ratio, klu.body_ratio, klu.lower_shadow_ratio/klu.body_ratio, klu.upper_shadow_ratio/klu.body_ratio)
return klu_list
def _detect_single_reversal_pattern(self, klu):
"""检测单根K线反转模式"""
body = abs(klu.close - klu.open)
upper_shadow = klu.high - max(klu.close, klu.open)
lower_shadow = min(klu.close, klu.open) - klu.low
total_range = klu.high - klu.low
# 避免除零
if total_range == 0:
return
body_ratio = body / total_range
upper_ratio = upper_shadow / total_range
lower_ratio = lower_shadow / total_range
#print(klu.time, upper_ratio, lower_ratio, body_ratio, upper_ratio/body_ratio, lower_ratio/body_ratio)
# 避免body_ratio为0时的除零错误
if body_ratio == 0:
return
# 锤子线/上吊线 - 反转信号
if lower_ratio / body_ratio >= 2:
# 锤子线:底部反转,需要前面一段
if klu.close > klu.open and klu.pre:
klu.set_pattern(Chan_KLU_PATTERN.HAMMER) # 底部反转
# 上吊线:顶部反转,需要前一根是上涨趋势
elif klu.close < klu.open and klu.pre:
klu.set_pattern(Chan_KLU_PATTERN.HANGING_MAN) # 顶部反转
# 倒锤子线/射击之星 - 反转信号
elif upper_ratio / body_ratio >= 2:
# 倒锤子线:底部反转,需要前一根是下跌趋势
if klu.close > klu.open and klu.pre:
klu.set_pattern(Chan_KLU_PATTERN.INVERTED_HAMMER) # 底部反转
# 射击之星:顶部反转,需要前一根是上涨趋势
elif klu.close < klu.open and klu.pre:
klu.set_pattern(Chan_KLU_PATTERN.SHOOTING_STAR) # 顶部反转
# 十字星 - 反转信号
elif body_ratio <= 0.1:
if upper_ratio > 0.4 and lower_ratio > 0.4:
klu.set_pattern(Chan_KLU_PATTERN.LONG_LEGGED_DOJI) # 强烈反转信号
elif upper_ratio > 0.4 and lower_ratio <= 0.1:
# 墓碑十字星:顶部反转,需要前一根是上涨趋势
if klu.pre and klu.pre.close > klu.pre.open:
klu.set_pattern(Chan_KLU_PATTERN.GRAVESTONE_DOJI) # 顶部反转
elif lower_ratio > 0.4 and upper_ratio <= 0.1:
# 蜻蜓十字星:底部反转,需要前一根是下跌趋势
if klu.pre and klu.pre.close < klu.pre.open:
klu.set_pattern(Chan_KLU_PATTERN.DRAGONFLY_DOJI) # 底部反转
else:
klu.set_pattern(Chan_KLU_PATTERN.DOJI) # 一般反转信号
def _detect_double_pattern(self, prev_klu, curr_klu):
"""检测两根K线形成的形态
包括吞没形态(看涨/看跌)乌云盖顶曙光初现
"""
# 如果前一根K线已经有形态,不再识别双K线形态
if prev_klu.pattern != Chan_KLU_PATTERN.UNKNOWN:
return
# 计算K线实体
prev_body = abs(prev_klu.close - prev_klu.open)
curr_body = abs(curr_klu.close - curr_klu.open)
# 判断K线颜色(阴阳)
prev_bullish = prev_klu.close > prev_klu.open
curr_bullish = curr_klu.close > curr_klu.open
# 检查是否存在长期趋势(至少需要5根K线的趋势)
def check_long_trend(klu, bullish_trend=True, min_bars=5):
"""检查是否存在长期趋势
bullish_trend=True: 检查上涨趋势
bullish_trend=False: 检查下跌趋势
min_bars: 最少需要多少根K线形成趋势
"""
if not klu or not klu.pre:
return False
return True
# 使用EMA指标判断长期趋势
if klu.ema52 > 0:
if bullish_trend and klu.close < klu.ema52:
return False
if not bullish_trend and klu.close > klu.ema52:
return False
# 检查连续的K线方向
count = 0
current = klu.pre
while current and count < min_bars:
if not current.pre:
break
if bullish_trend:
# 上涨趋势:当前收盘价高于前一根收盘价
if current.close <= current.pre.close:
break
else:
# 下跌趋势:当前收盘价低于前一根收盘价
if current.close >= current.pre.close:
break
count += 1
current = current.pre
return count >= min_bars
# 1. 看涨吞没形态:前阴后阳,后者完全吞没前者
# 要求前面有明显的下跌趋势
if not prev_bullish and curr_bullish and \
abs(curr_klu.open - prev_klu.close) < 10 and \
curr_klu.close > prev_klu.open and \
check_long_trend(prev_klu, bullish_trend=False, min_bars=5):
curr_klu.set_pattern(Chan_KLU_PATTERN.BULLISH_ENGULFING)
return
# 2. 看跌吞没形态:前阳后阴,后者完全吞没前者
# 要求前面有明显的上涨趋势
if prev_bullish and not curr_bullish and \
abs(curr_klu.open - prev_klu.close) < 10 and \
curr_klu.close < prev_klu.open and \
check_long_trend(prev_klu, bullish_trend=True, min_bars=5):
curr_klu.set_pattern(Chan_KLU_PATTERN.BEARISH_ENGULFING)
return
# 3. 乌云盖顶:前阳后阴,后者开盘价高于前者最高价,收盘价在前者实体中部以下
# 要求前面有明显的上涨趋势
if prev_bullish and not curr_bullish and \
curr_klu.open > prev_klu.high and \
curr_klu.close < (prev_klu.open + prev_klu.close) / 2 and \
curr_klu.close > prev_klu.open and \
check_long_trend(prev_klu, bullish_trend=True, min_bars=5):
curr_klu.set_pattern(Chan_KLU_PATTERN.DARK_CLOUD_COVER)
return
# 4. 曙光初现:前阴后阳,后者开盘价低于前者最低价,收盘价在前者实体中部以上
# 要求前面有明显的下跌趋势
if not prev_bullish and curr_bullish and \
curr_klu.open < prev_klu.low and \
curr_klu.close > (prev_klu.open + prev_klu.close) / 2 and \
curr_klu.close < prev_klu.open and \
check_long_trend(prev_klu, bullish_trend=False, min_bars=5):
curr_klu.set_pattern(Chan_KLU_PATTERN.PIERCING_LINE)
return
# 平顶和平底移至三根K线形态中判断
def _detect_triple_pattern(self, first_klu, second_klu, third_klu):
"""检测三根K线形成的形态
包括早晨之星黄昏之星平顶平底
"""
# 如果前两根K线已经有形态,不再识别三K线形态
if first_klu.pattern != Chan_KLU_PATTERN.UNKNOWN or \
second_klu.pattern != Chan_KLU_PATTERN.UNKNOWN:
return
# 判断K线颜色(阴阳)
first_bullish = first_klu.close > first_klu.open
second_bullish = second_klu.close > second_klu.open
third_bullish = third_klu.close > third_klu.open
# 计算实体大小
first_body = abs(first_klu.close - first_klu.open)
second_body = abs(second_klu.close - second_klu.open)
third_body = abs(third_klu.close - third_klu.open)
# 检查是否存在长期趋势(至少需要5根K线的趋势)
def check_long_trend(klu, bullish_trend=True, min_bars=5):
"""检查是否存在长期趋势
bullish_trend=True: 检查上涨趋势
bullish_trend=False: 检查下跌趋势
min_bars: 最少需要多少根K线形成趋势
"""
if not klu or not klu.pre:
return False
# 使用EMA指标判断长期趋势
if klu.ema52 > 0:
if bullish_trend and klu.close < klu.ema52:
return False
if not bullish_trend and klu.close > klu.ema52:
return False
# 检查连续的K线方向
count = 0
current = klu.pre
while current and count < min_bars:
if not current.pre:
break
if bullish_trend:
# 上涨趋势:当前收盘价高于前一根收盘价
if current.close <= current.pre.close:
break
else:
# 下跌趋势:当前收盘价低于前一根收盘价
if current.close >= current.pre.close:
break
count += 1
current = current.pre
return count >= min_bars
# 1. 早晨之星:第一根阴线,第二根十字星或小实体,第三根阳线
# 要求前面有明显的下跌趋势
if not first_bullish and third_bullish and \
second_body < first_body * 0.3 and \
third_body > first_body * 0.5 and \
max(second_klu.open, second_klu.close) < first_klu.close and \
min(second_klu.open, second_klu.close) < third_klu.open and \
third_klu.close > (first_klu.open + first_klu.close) / 2 and \
check_long_trend(first_klu, bullish_trend=False, min_bars=7):
third_klu.set_pattern(Chan_KLU_PATTERN.MORNING_STAR)
return
# 2. 黄昏之星:第一根阳线,第二根十字星或小实体,第三根阴线
# 要求前面有明显的上涨趋势
if first_bullish and not third_bullish and \
second_body < first_body * 0.3 and \
third_body > first_body * 0.5 and \
min(second_klu.open, second_klu.close) > first_klu.close and \
max(second_klu.open, second_klu.close) > third_klu.open and \
third_klu.close < (first_klu.open + first_klu.close) / 2 and \
check_long_trend(first_klu, bullish_trend=True, min_bars=7):
third_klu.set_pattern(Chan_KLU_PATTERN.EVENING_STAR)
return
# 3. 平顶:三根K线的最高点几乎相同(上升趋势中更有意义)
# 要求前面有明显的上涨趋势
if (abs(first_klu.high - second_klu.high) / first_klu.high < 0.0002 and
abs(second_klu.high - third_klu.high) / second_klu.high < 0.0002 and
check_long_trend(first_klu, bullish_trend=True, min_bars=7)):
# 额外确认:价格接近阻力位或关键技术指标
is_near_resistance = False
# 检查是否接近EMA52阻力位
if first_klu.ema52 > 0:
resistance_level = first_klu.ema52
if abs(first_klu.high - resistance_level) / resistance_level < 0.01:
is_near_resistance = True
# 检查是否有成交量确认(成交量减少表示上涨动能减弱)
volume_confirmation = False
if (first_klu.volume > 0 and second_klu.volume > 0 and third_klu.volume > 0 and
third_klu.volume < second_klu.volume and second_klu.volume < first_klu.volume):
volume_confirmation = True
if is_near_resistance or volume_confirmation:
third_klu.set_pattern(Chan_KLU_PATTERN.TWEEZER_TOP)
return
# 4. 平底:三根K线的最低点几乎相同(下降趋势中更有意义)
# 要求前面有明显的下跌趋势
if (abs(first_klu.low - second_klu.low) / first_klu.low < 0.0002 and
abs(second_klu.low - third_klu.low) / second_klu.low < 0.0002 and
check_long_trend(first_klu, bullish_trend=False, min_bars=7)):
# 额外确认:价格接近支撑位或关键技术指标
is_near_support = False
# 检查是否接近EMA52支撑位
if first_klu.ema52 > 0:
support_level = first_klu.ema52
if abs(first_klu.low - support_level) / support_level < 0.01:
is_near_support = True
# 检查是否有成交量确认(成交量减少表示下跌动能减弱)
volume_confirmation = False
if (first_klu.volume > 0 and second_klu.volume > 0 and third_klu.volume > 0 and
third_klu.volume < second_klu.volume and second_klu.volume < first_klu.volume):
volume_confirmation = True
if is_near_support or volume_confirmation:
third_klu.set_pattern(Chan_KLU_PATTERN.TWEEZER_BOTTOM)
return
+315
View File
@@ -0,0 +1,315 @@
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
from __future__ import annotations
from datetime import timedelta
from decimal import Decimal
import numpy as np
import pandas as pd
from pandas import DataFrame
from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS
from chanlun.core.ChanBSP import ChanBSP
from chanlun.core.ChanEnum import (
Chan_BI_DIR,
Chan_BSP_DIR,
Chan_BSP_TYPE,
Chan_FX_TYPE,
Chan_K_DIR,
Chan_KLC_FX,
Chan_KLC_STATE,
Chan_KLINE_DIR,
Chan_KLU_PATTERN,
Chan_PRICE_TREND,
Chan_SEG_DIR,
Chan_ZS_DIR,
)
from chanlun.core.ChanKLC import ChanKLC
from chanlun.core.ChanKLU import ChanKLU
from chanlun.core.ChanSBI import ChanSBI
from chanlun.core.ChanSEG import ChanSEG
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
from chanlun.indicators.ChanMACD import ChanMACD
class SegBuilderMixin:
def get_seg_list(self, bi_list):
seg_list = []
up_bi_list = []
down_bi_list = []
last_up_bi = None
last_down_bi = None
last_up_sbi = None
last_down_sbi = None
last_seg = None
up_sbi_list = []
down_sbi_list = []
look_for_bottom = False
look_for_top = False
for bi in bi_list:
#print(len(up_sbi_list), len(down_sbi_list))
if len(seg_list) > 0:
# Last seg is up
if last_seg.dir == Chan_SEG_DIR.UP:
if bi.dir == Chan_BI_DIR.DOWN:
if len(down_sbi_list) > 1:
# Check down sbi inclusion
included = last_down_sbi.check_bi_included(bi)
if not included:
down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir)
last_down_sbi.set_next(down_sbi)
last_down_sbi.set_end_bi(last_down_bi)
down_sbi.set_pre(last_down_sbi)
down_sbi_list.append(down_sbi)
fx = last_down_sbi.check_fx()
# Found top
if fx == Chan_FX_TYPE.TOP:
if look_for_top:
seg_list[-2].set_sure(bi)
look_for_top = False
#print(bi.start_time, look_for_top, "UP 1")
# Has gap and search for bottom fx
if last_down_sbi.has_fx_gap:
look_for_bottom = True
last_seg.pre_set_end_bi(bi_list[last_down_sbi.start_bi.index - 1])
seg = ChanSEG(last_down_sbi.start_bi, len(seg_list), Chan_SEG_DIR.DOWN, bi)
seg_list.append(seg)
last_seg.set_next(seg)
seg.set_pre(last_seg)
last_seg = seg
up_sbi_list = []
last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir)
up_sbi_list.append(last_up_sbi)
#up_sbi_list.append(last_up_sbi)
#print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 1")
#print(bi.start_time, look_for_top, "UP 2")
# No gap end SEG
else:
if look_for_bottom:
look_for_bottom = False
last_seg.set_start_bi(last_down_sbi.start_bi)
seg_list[-2].set_end_bi(bi_list[last_down_sbi.start_bi.index - 1], bi)
up_sbi_list = []
last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir)
up_sbi_list.append(last_up_sbi)
last_seg.add_bi(bi)
#up_sbi_list.append(last_up_sbi)
#print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 2")
#print(bi.start_time, look_for_top, "UP 3")
else:
last_seg.set_end_bi(bi_list[last_down_sbi.start_bi.index - 1], bi)
seg = ChanSEG(last_down_sbi.start_bi, len(seg_list), Chan_SEG_DIR.DOWN, bi)
seg_list.append(seg)
last_seg.set_next(seg)
seg.set_pre(last_seg)
last_seg = seg
#print(last_down_sbi.end_bi.start_time, "Normal UP SEG", last_up_sbi.start_bi.start_time, bi.start_time)
#l_up_sbi = up_sbi_list[-1]
up_sbi_list = []
last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir)
up_sbi_list.append(last_up_sbi)
#up_sbi_list.append(last_up_sbi)
#print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 3")
last_down_sbi = down_sbi
last_seg.add_bi(bi)
else:
if len(down_sbi_list) == 1:
included = last_down_sbi.check_bi_included(bi)
if not included:
down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir)
last_down_sbi.set_next(down_sbi)
last_down_sbi.set_end_bi(last_down_bi)
down_sbi.set_pre(last_down_sbi)
down_sbi_list.append(down_sbi)
last_down_sbi = down_sbi
#print(bi.start_time, look_for_top, "UP 4")
last_seg.add_bi(bi)
else:
last_down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir)
down_sbi_list.append(last_down_sbi)
last_seg.add_bi(bi)
#print(bi.start_time, look_for_top, "UP 5")
else:
if last_up_sbi:
included = last_up_sbi.check_bi_included(bi)
if not included:
up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir)
last_up_sbi.set_next(up_sbi)
last_up_sbi.set_end_bi(last_up_bi)
up_sbi.set_pre(last_up_sbi)
up_sbi_list.append(up_sbi)
last_up_sbi = up_sbi
#print(bi.start_time, look_for_top, "UP 6")
last_seg.add_bi(bi)
# Last seg is down
else:
if bi.dir == Chan_BI_DIR.UP:
if len(up_sbi_list) > 1:
# Check down sbi inclusion
included = last_up_sbi.check_bi_included(bi)
if not included:
up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir)
last_up_sbi.set_next(up_sbi)
last_up_sbi.set_end_bi(last_up_bi)
up_sbi.set_pre(last_up_sbi)
up_sbi_list.append(up_sbi)
fx = last_up_sbi.check_fx()
# Found bottom
if fx == Chan_FX_TYPE.BOTTOM:
if look_for_bottom:
seg_list[-2].set_sure(bi)
look_for_bottom = False
#print(bi.start_time, look_for_top, "DOWN 1")
# Has gap and search for bottom fx
if last_up_sbi.has_fx_gap:
look_for_top = True
last_seg.pre_set_end_bi(bi_list[last_up_sbi.start_bi.index - 1])
seg = ChanSEG(last_up_sbi.start_bi, len(seg_list), Chan_SEG_DIR.UP, bi)
seg_list.append(seg)
last_seg.set_next(seg)
seg.set_pre(last_seg)
last_seg = seg
down_sbi_list = []
last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir)
down_sbi_list.append(last_down_sbi)
#down_sbi_list.append(last_down_sbi)
#print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 1")
#print(bi.start_time, look_for_top, "DOWN 2")
# No gap end SEG
else:
if look_for_top:
look_for_top = False
last_seg.set_start_bi(last_up_sbi.start_bi)
seg_list[-2].set_end_bi(bi_list[last_up_sbi.start_bi.index - 1], bi)
down_sbi_list = []
last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir)
down_sbi_list.append(last_down_sbi)
last_seg.add_bi(bi)
#down_sbi_list.append(last_down_sbi)
#print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 2")
#print(bi.start_time, look_for_top, "DOWN 3")
else:
last_seg.set_end_bi(bi_list[last_up_sbi.start_bi.index - 1], bi)
seg = ChanSEG(last_up_sbi.start_bi, len(seg_list), Chan_SEG_DIR.UP, bi)
#print(last_up_sbi.start_bi.start_time)
last_seg.set_next(seg)
seg.set_pre(last_seg)
seg_list.append(seg)
last_seg = seg
#print(last_up_sbi.end_bi.start_time, "Normal DOWN SEG", last_down_sbi.start_bi.start_time, bi.start_time)
down_sbi_list = []
last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir)
down_sbi_list.append(last_down_sbi)
#down_sbi_list.append(last_down_sbi)
#print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 3")
last_up_sbi = up_sbi
last_seg.add_bi(bi)
else:
if len(up_sbi_list) == 1:
#last_up_sbi = up_sbi_list[-1]
included = last_up_sbi.check_bi_included(bi)
if not included:
up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir)
last_up_sbi.set_next(up_sbi)
last_up_sbi.set_end_bi(last_up_bi)
up_sbi.set_pre(last_up_sbi)
up_sbi_list.append(up_sbi)
last_up_sbi = up_sbi
last_seg.add_bi(bi)
#print(bi.start_time, look_for_top, "DOWN 4")
else:
last_up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir)
up_sbi_list.append(last_up_sbi)
last_seg.add_bi(bi)
#print(bi.start_time, look_for_top, "DOWN 5")
else:
if last_down_sbi:
included = last_down_sbi.check_bi_included(bi)
if not included:
down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir)
last_down_sbi.set_next(down_sbi)
last_down_sbi.set_end_bi(last_down_bi)
down_sbi.set_pre(last_down_sbi)
down_sbi_list.append(down_sbi)
last_down_sbi = down_sbi
last_seg.add_bi(bi)
#print(bi.start_time, look_for_top, look_for_bottom, "DOWN 6")
# len(seg_list) = 0
else:
if bi.check_overlap():
if bi.dir == Chan_BI_DIR.UP:
seg = ChanSEG(bi, len(seg_list), Chan_SEG_DIR.UP, bi)
last_up_bi = bi
last_up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir)
seg_list.append(seg)
last_seg = seg
#print(bi.start_time, 'Create first UP SEG')
else:
seg = ChanSEG(bi, len(seg_list), Chan_SEG_DIR.DOWN, bi)
last_down_bi = bi
last_down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir)
seg_list.append(seg)
last_seg = seg
#print(bi.start_time, 'Create first DOWN SEG')
if bi.dir == Chan_BI_DIR.UP:
last_up_bi = bi
up_bi_list.append(bi)
else:
last_down_bi = bi
down_bi_list.append(bi)
"""
if len(seg_list) > 1:
seg = seg_list[-1]
last_seg = seg_list[-2]
last_seg_bi = last_seg.bi_list[-3]
bi_index = seg.start_bi.index
for i in range(bi_index, len(bi_list) - 1):
# last seg is down
if seg.dir == Chan_SEG_DIR.UP:
if bi_list[i].dir == Chan_BI_DIR.UP:
last_seg_peak = last_seg_bi.high
if bi_list[i].high > last_seg_peak:
# The confirmed
print("Last UP seg is broken, create a new seg. 1")
seg.pre_set_end_bi(bi_list[i])
seg = ChanSEG(bi_list[i+1], len(seg_list), Chan_SEG_DIR.DOWN, bi)
seg_list.append(seg)
last_seg = seg_list[-2]
if len(last_seg.bi_list) > 3:
last_seg_bi = last_seg.bi_list[-3]
else:
if bi_list[i].dir == Chan_BI_DIR.DOWN:
last_seg_peak = last_seg_bi.low
if bi_list[i].low < last_seg_peak:
print("Last DOWN seg is broken, create a new seg. 1")
seg.pre_set_end_bi(bi_list[i])
seg = ChanSEG(bi_list[i+1], len(seg_list), Chan_SEG_DIR.UP, bi)
seg_list.append(seg)
last_seg = seg_list[-2]
if len(last_seg.bi_list) > 3:
last_seg_bi = last_seg.bi_list[-3]
else:
if len(seg_list) == 1:
last_seg = seg_list[-1]
bi_index = last_seg.bi_list[0].index
for i in range(bi_index, len(bi_list) - 1):
if i > bi_index + 2:
last_seg_peak = bi_list[i-2].high
# last seg is down
if last_seg.dir == Chan_SEG_DIR.DOWN:
if bi_list[i].dir == Chan_BI_DIR.UP:
if bi_list[i].high > last_seg_peak:
print("Last seg is broken, create a new seg. 2")
last_seg.pre_set_end_bi(bi_list[i-1])
seg = ChanSEG(bi_list[i], len(seg_list), Chan_SEG_DIR.UP, bi)
seg_list.append(seg)
last_seg = seg
last_seg_bi = bi_list[i]
break
"""
#self.cal_bi_zs(seg_list)
return seg_list
+702
View File
@@ -0,0 +1,702 @@
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
from __future__ import annotations
from datetime import timedelta
from decimal import Decimal
import numpy as np
import pandas as pd
from pandas import DataFrame
from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS
from chanlun.core.ChanBSP import ChanBSP
from chanlun.core.ChanEnum import (
Chan_BI_DIR,
Chan_BSP_DIR,
Chan_BSP_TYPE,
Chan_FX_TYPE,
Chan_K_DIR,
Chan_KLC_FX,
Chan_KLC_STATE,
Chan_KLINE_DIR,
Chan_KLU_PATTERN,
Chan_PRICE_TREND,
Chan_SEG_DIR,
Chan_ZS_DIR,
)
from chanlun.core.ChanKLC import ChanKLC
from chanlun.core.ChanKLU import ChanKLU
from chanlun.core.ChanSBI import ChanSBI
from chanlun.core.ChanSEG import ChanSEG
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
from chanlun.indicators.ChanMACD import ChanMACD
class ZsBuilderMixin:
def get_zs_state(self, df):
bi_list = self.cal_bi_list(self.get_klc_list(self.get_kl_data(df)))
seg_list = self.get_seg_list(bi_list)
zs_list = self.calculate_zs(seg_list)
for zs in zs_list:
last_zs = zs
return zs_list
def cal_bi_zs(self, seg_list):
bi_zs_list = []
for seg in seg_list:
zs_list = seg.cal_bi_zs()
if len(zs_list) > 0:
bi_zs_list = list(bi_zs_list) + list(zs_list)
return bi_zs_list
# 跨段不相连的中枢
def cal_bi_zs_list(self, bi_list):
"""
根据缠论笔中枢定义计算中枢参照 get_zs_list 线段中枢判断规则
从第4根笔开始索引3每3根笔为一组检查
上涨中枢后中枢 zd > 前中枢 zg不重叠上移
下跌中枢后中枢 zg < 前中枢 zd不重叠下移
中枢可按两笔一组继续扩展到5根7...
"""
bi_zs_list = []
if len(bi_list) < 3:
return bi_zs_list
last_zs = None
start_idx = 3
while start_idx < len(bi_list):
if start_idx + 2 >= len(bi_list):
break
bi1 = bi_list[start_idx]
bi2 = bi_list[start_idx + 1]
bi3 = bi_list[start_idx + 2]
if not (bi1.is_sure and bi2.is_sure and bi3.is_sure):
start_idx += 1
continue
zg = min(bi1.high, bi2.high, bi3.high)
zd = max(bi1.low, bi2.low, bi3.low)
if zg <= zd:
start_idx += 1
continue
valid = False
if last_zs is None:
if bi1.dir == Chan_BI_DIR.DOWN:
zs_dir = Chan_ZS_DIR.UP
valid = (bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN)
else:
zs_dir = Chan_ZS_DIR.DOWN
valid = (bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP)
else:
is_up_zs = zg > last_zs.zg
is_down_zs = zd < last_zs.zd
if is_up_zs:
zs_dir = Chan_ZS_DIR.UP
valid = (bi1.dir == Chan_BI_DIR.DOWN and bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN)
elif is_down_zs:
zs_dir = Chan_ZS_DIR.DOWN
valid = (bi1.dir == Chan_BI_DIR.UP and bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP)
if not valid:
start_idx += 1
continue
gg = max(bi1.high, bi2.high, bi3.high)
dd = min(bi1.low, bi2.low, bi3.low)
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
zs.set_zg(zg)
zs.set_zd(zd)
zs.set_gg(gg)
zs.set_dd(dd)
zs.is_sure = False
zs.bi_list = [bi1, bi2, bi3]
added_after_leave = []
leave_index = start_idx + 4
while leave_index < len(bi_list):
b = bi_list[leave_index]
if not b.is_sure:
break
if b.high >= zs.zd and b.low <= zs.zg:
added_after_leave.append(b.pre)
added_after_leave.append(b)
else:
break
leave_index += 2
if added_after_leave:
bis_for_zs = list(zs.bi_list) + list(added_after_leave)
bi_highs = [bi.high for bi in bis_for_zs]
bi_lows = [bi.low for bi in bis_for_zs]
zs.set_gg(max(bi_highs))
zs.set_dd(min(bi_lows))
zs.bi_list = bis_for_zs
bi = bis_for_zs[-1]
if bi.is_sure:
zs.set_end_bi(bi, bi.sure_time)
start_idx = start_idx + len(added_after_leave)
else:
zs.set_end_bi(bi3, bi3.sure_time)
if last_zs:
last_zs.set_next(zs)
zs.set_pre(last_zs)
bi_zs_list.append(zs)
last_zs = zs
start_idx += 4
if last_zs:
last_zs.is_sure = bi_list[-1].is_sure
if last_zs and not last_zs.is_sure:
if last_zs.bi_list and len(last_zs.bi_list) > 0:
last_bi_of_zs = last_zs.bi_list[-1]
last_bi_idx = -1
for i, bi in enumerate(bi_list):
if bi == last_bi_of_zs:
last_bi_idx = i
break
has_leave = False
if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list):
for i in range(last_bi_idx + 1, len(bi_list)):
bi = bi_list[i]
if bi.is_sure:
leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \
(bi.high < last_zs.zd and bi.low < last_zs.zd)
if leave:
has_leave = True
break
if has_leave:
if last_bi_of_zs.is_sure:
last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time)
return bi_zs_list
def get_bi_zs_list(self, bi_list):
"""
根据缠论笔中枢定义计算中枢完全参照 get_seg_zs_list 线段中枢判断规则
从第4根笔开始索引3每3根笔为一组检查
上涨中枢后中枢 zd > 前中枢 zg不重叠上移
下跌中枢后中枢 zg < 前中枢 zd不重叠下移
盘整/扩张后中枢与前中枢整体区间有交集 合并扩展
中枢可按两笔一组继续扩展到5根7...
"""
bi_zs_list = []
if len(bi_list) < 3:
return bi_zs_list
last_zs = None
start_idx = 3
while start_idx < len(bi_list):
if start_idx + 2 >= len(bi_list):
break
bi1 = bi_list[start_idx]
bi2 = bi_list[start_idx + 1]
bi3 = bi_list[start_idx + 2]
if not (bi1.is_sure and bi2.is_sure and bi3.is_sure):
start_idx += 1
continue
zg = min(bi1.high, bi2.high, bi3.high)
zd = max(bi1.low, bi2.low, bi3.low)
if zg <= zd:
start_idx += 1
continue
valid = False
if last_zs is None:
if bi1.dir == Chan_BI_DIR.DOWN:
zs_dir = Chan_ZS_DIR.UP
valid = (bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN)
else:
zs_dir = Chan_ZS_DIR.DOWN
valid = (bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP)
else:
is_up_zs = zd > last_zs.zg
is_down_zs = zg < last_zs.zd
if is_up_zs:
zs_dir = Chan_ZS_DIR.UP
valid = (bi1.dir == Chan_BI_DIR.DOWN and bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN)
elif is_down_zs:
zs_dir = Chan_ZS_DIR.DOWN
valid = (bi1.dir == Chan_BI_DIR.UP and bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP)
create_new_zs = False
if not valid:
# 如果新中枢和前一个中枢的中枢区间有重叠,不形成新中枢,合并扩展
if last_zs is not None:
is_in_last_zs = (zd > last_zs.zd and zd < last_zs.zg) or \
(zg < last_zs.zg and zg > last_zs.zd) or \
(zg > last_zs.zg and zd < last_zs.zd) or \
(zg < last_zs.zg and zd > last_zs.zd)
if is_in_last_zs:
# 扩展当前中枢:将 bi1-bi3 加入 last_zs
for bi in [bi1, bi2, bi3]:
if bi not in last_zs.bi_list:
last_zs.add_bi(bi)
create_new_zs = False
else:
start_idx += 1
continue
else:
start_idx += 1
continue
else:
create_new_zs = True
# 新中枢形成时确认前一个中枢
if last_zs and create_new_zs:
last_bi = last_zs.bi_list[-1]
if last_bi and last_bi.is_sure:
last_zs.is_sure = True
last_zs.set_end_bi(last_bi, last_bi.sure_time)
zs = last_zs
if create_new_zs:
gg = max(bi1.high, bi2.high, bi3.high)
dd = min(bi1.low, bi2.low, bi3.low)
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
zs.set_zg(zg)
zs.set_zd(zd)
zs.set_gg(gg)
zs.set_dd(dd)
zs.is_sure = False
zs.bi_list = [bi1, bi2, bi3]
# 离开后回抽扩展检查
added_after_leave = []
leave_index = start_idx + 4
while leave_index < len(bi_list):
b = bi_list[leave_index]
if not b.is_sure:
break
if b.high >= zs.zd and b.low <= zs.zg:
added_after_leave.append(b.pre)
added_after_leave.append(b)
else:
break
leave_index += 2
if added_after_leave:
bis_for_zs = list(zs.bi_list) + list(added_after_leave)
bi_highs = [bi.high for bi in bis_for_zs]
bi_lows = [bi.low for bi in bis_for_zs]
zs.set_gg(max(bi_highs))
zs.set_dd(min(bi_lows))
zs.bi_list = bis_for_zs
bi = bis_for_zs[-1]
if bi.is_sure:
zs.set_end_bi(bi, bi.sure_time)
start_idx = start_idx + len(added_after_leave)
else:
if create_new_zs:
zs.set_end_bi(bi3, bi3.sure_time)
if create_new_zs:
if last_zs:
last_zs.set_next(zs)
zs.set_pre(last_zs)
bi_zs_list.append(zs)
last_zs = zs
start_idx += 4
# 最后一个中枢:根据 bi_list 最后一笔确认状态
if last_zs:
last_zs.is_sure = bi_list[-1].is_sure
if last_zs and not last_zs.is_sure:
if last_zs.bi_list and len(last_zs.bi_list) > 0:
last_bi_of_zs = last_zs.bi_list[-1]
last_bi_idx = -1
for i, bi in enumerate(bi_list):
if bi == last_bi_of_zs:
last_bi_idx = i
break
has_leave = False
if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list):
for i in range(last_bi_idx + 1, len(bi_list)):
bi = bi_list[i]
if bi.is_sure:
leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \
(bi.high < last_zs.zd and bi.low < last_zs.zd)
if leave:
has_leave = True
break
if has_leave:
if last_bi_of_zs.is_sure:
last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time)
return bi_zs_list
def cal_bi_zs_list_pure(self, bi_list):
bi_zs_list = []
if len(bi_list) < 3:
return bi_zs_list
def get_zs_range(bis):
bis_list = bis[0:3]
zg = min(bi.high for bi in bis_list)
zd = max(bi.low for bi in bis_list)
dd = min(bi.low for bi in bis_list)
gg = max(bi.high for bi in bis_list)
return zg, zd, dd, gg
def is_bi_overlap_range(bi, zg, zd):
return bi.high >= zd and bi.low <= zg
def check_zs_position_filter(last_zs, zg, zd, bis):
if last_zs is None:
return True
if zg <= last_zs.zd:
return bis[0].dir == Chan_BI_DIR.UP and bis[-1].dir == Chan_BI_DIR.UP
if zd >= last_zs.zg:
return bis[0].dir == Chan_BI_DIR.DOWN and bis[-1].dir == Chan_BI_DIR.DOWN
return True
def set_zs_bi_list(zs, bis):
zs.bi_list = list(bis)
for bi in zs.bi_list:
bi.set_bi_zs(zs)
#zs.set_gg(max(bi.high for bi in zs.bi_list))
#zs.set_dd(min(bi.low for bi in zs.bi_list))
zs.classify_zs()
last_zs = None
start_idx = 0
while start_idx + 2 < len(bi_list):
bi1 = bi_list[start_idx]
bi2 = bi_list[start_idx + 1]
bi3 = bi_list[start_idx + 2]
if not (bi1.is_sure and bi2.is_sure and bi3.is_sure):
start_idx += 1
continue
if not (bi1.dir != bi2.dir and bi1.dir == bi3.dir):
start_idx += 1
continue
zg, zd, dd, gg = get_zs_range([bi1, bi2, bi3])
if zg <= zd:
start_idx += 1
continue
bis_for_zs = [bi1, bi2, bi3]
extend_idx = start_idx + 3
while extend_idx + 1 < len(bi_list):
leave_bi = bi_list[extend_idx]
back_bi = bi_list[extend_idx + 1]
if not (leave_bi.is_sure and back_bi.is_sure):
break
if not is_bi_overlap_range(back_bi, zg, zd):
break
bis_for_zs.append(leave_bi)
bis_for_zs.append(back_bi)
extend_idx += 2
if not check_zs_position_filter(last_zs, zg, zd, bis_for_zs):
start_idx += 1
continue
zs_dir = Chan_ZS_DIR.UP if bi1.dir == Chan_BI_DIR.DOWN else Chan_ZS_DIR.DOWN
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
zs.set_zg(zg)
zs.set_zd(zd)
zs.set_dd(dd)
zs.set_gg(gg)
set_zs_bi_list(zs, bis_for_zs)
zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time)
if last_zs:
last_zs.set_next(zs)
zs.set_pre(last_zs)
bi_zs_list.append(zs)
last_zs = zs
start_idx = start_idx + len(bis_for_zs)
# 与 cal_bi_zs_list 一致:最后一笔未确认时末中枢标为未完成;若其后已出现确认的离开笔,仍按离开前最后一笔确认中枢结束
if last_zs:
last_zs.is_sure = bi_list[-1].is_sure
if last_zs and not last_zs.is_sure:
if last_zs.bi_list and len(last_zs.bi_list) > 0:
last_bi_of_zs = last_zs.bi_list[-1]
last_bi_idx = -1
for i, bi in enumerate(bi_list):
if bi == last_bi_of_zs:
last_bi_idx = i
break
has_leave = False
if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list):
for i in range(last_bi_idx + 1, len(bi_list)):
bi = bi_list[i]
if bi.is_sure:
leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \
(bi.high < last_zs.zd and bi.low < last_zs.zd)
if leave:
has_leave = True
break
if has_leave:
if last_bi_of_zs.is_sure:
last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time)
return bi_zs_list
def get_zs_list(self, bi_list, seg_list):
"""兼容历史 API:线段中枢列表。"""
return self.get_seg_zs_list(seg_list)
def calculate_seg_zs(self, seg_list):
return self.get_seg_zs_list(seg_list)
def get_seg_zs_list(self, seg_list):
"""
根据缠论线段中枢定义计算中枢
从第4根线段开始索引3每3根线段为一组检查
上涨中枢后中枢 zd > 前中枢 zg不重叠上移
下跌中枢后中枢 zg < 前中枢 zd不重叠下移
盘整/扩张后中枢与前中枢整体区间GG/DD有交集
中枢可按两段一组继续扩展到5根7...
"""
zs_list = []
if len(seg_list) < 3:
return zs_list
last_zs = None
# 从第4根线段开始(索引3),每3根为一组
start_idx = 3
while start_idx < len(seg_list):
# 取连续3个线段
if start_idx + 2 >= len(seg_list):
break
seg1 = seg_list[start_idx]
seg2 = seg_list[start_idx + 1]
seg3 = seg_list[start_idx + 2]
# 三个线段都必须是已确认的
if not (seg1.is_sure and seg2.is_sure and seg3.is_sure):
start_idx += 1
continue
# 计算这3个线段的中枢区间
zg = min(seg1.high, seg2.high, seg3.high)
zd = max(seg1.low, seg2.low, seg3.low)
if zg <= zd:
start_idx += 1
#print(seg1.start_bi.start_klc.end_time, "not valid", zg, zd)
continue
# 判断中枢类型(按注释定义)
# 上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移)
# 下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移)
# 盘整/扩张:后中枢与前中枢区间有交集
if last_zs is None:
# 第一个中枢仅按线段形态判定方向
if seg1.dir == Chan_SEG_DIR.DOWN:
# 下跌+上涨+下跌,对应上涨中枢
zs_dir = Chan_ZS_DIR.UP
valid = (seg2.dir == Chan_SEG_DIR.UP and seg3.dir == Chan_SEG_DIR.DOWN)
else:
# 上涨+下跌+上涨,对应下跌中枢
zs_dir = Chan_ZS_DIR.DOWN
valid = (seg2.dir == Chan_SEG_DIR.DOWN and seg3.dir == Chan_SEG_DIR.UP)
else:
is_up_zs = zd > last_zs.zg
is_down_zs = zg < last_zs.zd
if is_up_zs:
# 不重叠上移
zs_dir = Chan_ZS_DIR.UP
valid = (seg1.dir == Chan_SEG_DIR.DOWN and seg2.dir == Chan_SEG_DIR.UP and seg3.dir == Chan_SEG_DIR.DOWN)
elif is_down_zs:
# 不重叠下移
zs_dir = Chan_ZS_DIR.DOWN
valid = (seg1.dir == Chan_SEG_DIR.UP and seg2.dir == Chan_SEG_DIR.DOWN and seg3.dir == Chan_SEG_DIR.UP)
create_new_zs = False
# 验证是否有效
if not valid:
# 如果新中枢和前一个中枢的中枢区间有重叠,不行成新中枢需要合并两个中枢
is_in_last_zs = (zd > last_zs.zd and zd < last_zs.zg) or (zg < last_zs.zg and zg > last_zs.zd) or (zg > last_zs.zg and zd < last_zs.zd) or (zg < last_zs.zg and zd > last_zs.zd)
if is_in_last_zs:
#print(seg1.start_time, "New zs is in last zs, not valid")
last_zs.extend_zs(seg_list[last_zs.seg_list[-1].index:(seg3.index + 1)])
create_new_zs = False
else:
start_idx += 1
continue
else:
create_new_zs = True
if last_zs and create_new_zs:
last_seg = last_zs.seg_list[-1]
last_bi = last_seg.end_bi
if last_bi:
last_zs.is_sure = True
last_zs.set_end_klc(last_bi.end_klc, last_bi.sure_time, 0, last_seg)
last_zs.set_end_seg(last_seg)
zs = last_zs
if create_new_zs:
# 创建新中枢
gg = max(seg1.high, seg2.high, seg3.high)
dd = min(seg1.low, seg2.low, seg3.low)
zs = ChanZS(seg1, len(zs_list), zs_dir)
zs.set_zg(zg)
zs.set_zd(zd)
zs.set_gg(gg)
zs.set_dd(dd)
zs.is_sure = False
zs.seg_list = [seg1, seg2, seg3]
# 若第二线段与 [zd,zg] 重叠(如离开后回抽回到前中枢)则并入扩展
added_after_leave = []
leave_index = start_idx + 4
is_break = False
while leave_index < len(seg_list):
s = seg_list[leave_index]
if not s.is_sure:
break
sh = max(s.start_bi.high, s.end_bi.high) if s.end_bi else s.start_bi.high
sl = min(s.start_bi.low, s.end_bi.low) if s.end_bi else s.start_bi.low
if sh >= zs.zd and sl <= zs.zg:
added_after_leave.append(s.pre)
added_after_leave.append(s)
leave_index += 2
else:
next_seg = s.next
if next_seg and next_seg.is_sure:
if next_seg.dir == Chan_SEG_DIR.UP:
if next_seg.high <= zs.zg and next_seg.low >= zs.zd:
leave_index += 2
continue
else:
is_break = True
else:
if next_seg.low >= zs.zd and next_seg.low <= zs.zg:
leave_index += 2
continue
else:
is_break = True
else:
break
if is_break:
break
if added_after_leave:
#print(len(added_after_leave))
segs_for_zs = list(zs.seg_list) + list(added_after_leave)
seg_highs = [s.high for s in segs_for_zs]
seg_lows = [s.low for s in segs_for_zs]
zs.set_gg(max(seg_highs))
zs.set_dd(min(seg_lows))
zs.seg_list = segs_for_zs
seg = segs_for_zs[-1]
#if seg.end_bi:
#zs.set_end_klc(seg.end_bi.end_klc, seg.sure_time, 0, seg)
#zs.set_end_seg(seg)
#zs.is_sure = True
start_idx = start_idx + len(added_after_leave)
if last_zs and last_zs.index != zs.index:
last_zs.set_next(zs)
zs.set_pre(last_zs)
zs_list.append(zs)
last_zs = zs
# 移动到下一组
start_idx += 4
if last_zs:
last_zs.is_sure = seg_list[-1].is_sure
"""
# 处理最后一个未确认的中枢 - 不自动扩展,保持未完成状态
if last_zs and not last_zs.is_sure:
# 获取中枢最后一个线段的索引
if last_zs.seg_list and len(last_zs.seg_list) > 0:
last_seg_of_zs = last_zs.seg_list[-1]
# 找到这个线段在seg_list中的索引
last_seg_idx = -1
for i, seg in enumerate(seg_list):
if seg == last_seg_of_zs:
last_seg_idx = i
break
# 从中枢最后一个线段之后检查是否有离开
has_leave = False
if last_seg_idx >= 0 and last_seg_idx + 1 < len(seg_list):
for i in range(last_seg_idx + 1, len(seg_list)):
seg = seg_list[i]
if seg.is_sure:
# 检查是否离开中枢
leave = (seg.low > last_zs.zg and seg.high > last_zs.zg) or \
(seg.high < last_zs.zd and seg.low < last_zs.zd)
if leave:
has_leave = True
break
if not has_leave:
# 没有离开,保持未完成状态
pass
else:
# 有离开,确认中枢
if last_seg_of_zs.end_bi:
#print(last_seg_of_zs.start_time, "last_seg_of_zs.end_time", last_seg_of_zs.end_time)
last_zs.set_end_klc(last_seg_of_zs.end_bi.end_klc, last_seg_of_zs.sure_time, 0, last_seg_of_zs)
last_zs.set_end_seg(last_seg_of_zs)
last_zs.is_sure = True
"""
return zs_list
def get_big_zs_list(self, zs_list):
"""
中枢扩张将区间重叠的连续中枢合并为大级别中枢便于显示更大级别的震荡区间
重叠定义两中枢 [zd,zg] 有交集 (zs_i.zg >= zs_j.zd and zs_i.zd <= zs_j.zg)
"""
big_list = []
if len(zs_list) < 2:
return big_list
i = 0
while i < len(zs_list):
group = [zs_list[i]]
j = i + 1
while j < len(zs_list):
cur = zs_list[j]
# 与当前组内任一中枢有重叠即算扩张(通常只需与组内最后一个比)
last_in_group = group[-1]
overlap = (last_in_group.zg >= cur.zd and last_in_group.zd <= cur.zg)
if overlap:
group.append(cur)
j += 1
else:
break
if len(group) >= 2:
big = ChanZS_Big(group)
big.index = len(big_list)
big_list.append(big)
i = j if len(group) >= 2 else i + 1
return big_list
+22 -13
View File
@@ -9,22 +9,20 @@ warnings.filterwarnings(
from datetime import timedelta
from pandas import DataFrame
from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN
from ChanKLU import ChanKLU
from ChanKLC import ChanKLC
from ChanBI import ChanBI
from ChanSBI import ChanSBI
from ChanSEG import ChanSEG
from ChanZS import ChanZS
from ChanBSP import ChanBSP
import talib.abstract as ta
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN
from chanlun.core.ChanKLU import ChanKLU
from chanlun.core.ChanKLC import ChanKLC
from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanSBI import ChanSBI
from chanlun.core.ChanSEG import ChanSEG
from chanlun.core.ChanZS import ChanZS
from chanlun.core.ChanBSP import ChanBSP
import pandas as pd
from technical.util import resample_to_interval
from decimal import Decimal
import numpy as np
from ChanMACD import ChanMACD
from TF_DF import TF_DF
from ChanZone import StructureZone, StructureZoneConfig, analyze_structure_zones
from chanlun.indicators.ChanMACD import ChanMACD
from chanlun.pipeline.timeframe import TF_DF
from chanlun.analysis.ChanZone import StructureZone, StructureZoneConfig, analyze_structure_zones
class ChanLun():
def __init__(self):
@@ -171,6 +169,8 @@ class ChanLun():
return self.tf_df.find_second_bsp(bi_list, first_bsp_list)
def find_all_bsp(self, bi_list, bi_zs_list):
return self.tf_df.find_all_bsp(bi_list, bi_zs_list)
def cal_fast_bsp(self, df=None, bi_zs_list=None, htf_chan=None, with_htf=True, timeframe=None, **kw):
return self.tf_df.cal_fast_bsp(df, bi_zs_list, htf_chan, with_htf, timeframe, **kw)
def get_zs_list(self, bi_list, seg_list):
return self.tf_df.get_zs_list(bi_list, seg_list)
def cal_bi_zs(self, seg_list):
@@ -178,6 +178,15 @@ class ChanLun():
def cal_bi_zs_list(self, bi_list):
#return self.tf_df.cal_bi_zs(bi_list)
return self.tf_df.cal_bi_zs_list(bi_list)
def cal_bi_zs_list_pure(self, bi_list):
return self.tf_df.cal_bi_zs_list_pure(bi_list)
def init_stream(self, dataframe, interval=1, timeframe=None):
self.tf_df.init_stream(dataframe, interval, timeframe)
return self.tf_df
def append_bar(self, row):
return self.tf_df.append_bar(row)
def replace_last_bar(self, row):
return self.tf_df.replace_last_bar(row)
def get_bi_zs_list(self, bi_list):
return self.tf_df.get_bi_zs_list(bi_list)
def get_decimal(self, value):

Some files were not shown because too many files have changed in this diff Show More