60 Commits
Author SHA1 Message Date
jackyu66gitandCursor 2b1b500108 笔确认改回原著口径,并留下严格笔对照。
顶底分型仍用分型自身高低判断重叠;三K盒子那种更严的算法先注释保留。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 02:52:01 +08:00
jackyu66gitandCursor c22cd48f36 Web 加上资金面/情绪叠图,并收紧默认图面。
默认主/次/次次改为 45m/15m/5m、开始时间一周;SD/CD 按 hist 摆位置和箭头;去掉笔线段背驰与第四类勾选。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 02:25:44 +08:00
jackyu66git b301ad22c9 fix: pipeline MACD 参数统一为标准 12/26/9(与 web/交易所一致) 2026-09-12 02:15:11 +08:00
jackyu66gitandCursor 2cd50f1e01 主图增加笔/线段背驰与面积数字,并修正 SD99999 显示。
三周期分开关控制,只画数字不画图标;线段面积比沿用同向笔面积口径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 04:33:40 +08:00
jackyu66git 6b72ba226b Merge remote-tracking branch 'origin/chan' into chan 2026-08-31 23:03:28 +08:00
jackandCursor 080ff7d20e 整点在线推 Telegram,并带上搬运管道的新鲜度
执行器活着不代表链路活着——搬运死了一样心跳正常、一样什么都不做。
所以每小时那条必须读 ship_alive.json:ssh 是否在连、文件是否还在刷。
连上/断开立刻落盘,不靠 5 分钟心跳,否则第一轮会误报上游断了。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:57:11 +08:00
jackandCursor 3bbd92784c 下单失败必须推 Telegram,并让心跳判读「做了却没建上」
实盘首日丢掉 4 个信号、白跑 5 小时,根因是 40774,但**没有任何推送**。
日志里一直在报,可外在表现和"没信号"一模一样——这正是整套通知设计要防的
那类静默经济损失,却恰好漏了下单失败这一条。

现在:入场被拒或止盈挂不上都推。按信号聚合成一条,不按腿推(避免一个信号
两条)。两腿全失败时说明"这个信号丢了"并带累计失败次数与常见原因;部分腿
失败时说明"收益结构已偏离设计"——那种情况仓位是半的,不是设计的两腿结构。

心跳原先只把数字并排列出来:"已做 4 · 在场 0/3" 那 5 小时一直在打,但没有
一处说这是异常。现在分开计 n_built 与 n_order_fail,做了却一次没建上直接
打 。只看 n_took 分不出"做了"和"建上了"。

status.sh 同样加判读:统计 entry_fail 次数并打出最后一条错误与码表。

tp_fail 现在也记进 live_trades.jsonl,原先只打日志不落盘。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:48:56 +08:00
jackandCursor 77056a8318 下单体去掉 tradeSide:账户是单向持仓,带它会被 40774 全拒
实盘首批 4 个信号全部下单失败,8 次尝试(4 信号 × 2 腿)都是
[40774] The order type for unilateral position must also be the unilateral
position type。投递链路其余部分完全正常:延后 0.0~0.2s、ssh 在线 255 分钟
零重连、去重 0、闸全过。纯粹是下单体语法。

官方文档:单向持仓下要忽略 tradeSide(side 取 buy/sell 即可),平仓用
side 取反 + reduceOnly=YES;而 reduceOnly 也只在单向模式下有效。双向持仓
才需要 tradeSide=open/close。我们发的是双向语法打到单向账户,是整体拒单
而不是部分降级。

三处下单体(entry_with_stop / tp_limit / close_market)都去掉 tradeSide。
reduceOnly 保留——它在单向模式下正是防止反手开出反向仓的那个参数。

另加 setup_position_mode():把模式钉成单向并读回核对,与既有的"每次启动
都设一遍逐仓和杠杆、不假设交易所侧状态"一致。调用点放在 reconcile 之后,
因为有持仓或挂单时交易所不允许切换。读回不是单向时推 Telegram 告警。

空跑不可能验到这个:dry=True 在发请求之前就返回假成功。这类"下单体字段
组合"的问题只有真单会暴露,和之前 oid 里 - 字符那条同一类。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:46:17 +08:00
jackyu66gitandCursor f14ef540c1 research: 订正——「额外信号 74%」是按条数统计的放大,按中枢是 52%
用户指出「原来的计算是没有问题的,是你算错了」。改用逐笔对账(不做统计,
把两边中间量全摆出来),结论一半一半。

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:31:46 +08:00
jackandCursor 1ee4c3d4e1 记 §3.6:scan=200 补测,意外地落在对的位置
scan 从未在 1m 加当前出场结构下被单独验过——step29 的网格里它和
pullback_win/tol 联动、跑在 15m 上用旧出场参数,分离不出单独效应。文档里
原先只有一行表格、没有依据。

澄清两个口径:scan 只约束找突破,入场由 pullback_win 单独限制且不受
scan_end 约束,实际触达 230 根;中枢跨度再长也不占窗口,因为 available_ts
取 bis[-1] 落在右边缘(1m 上跨度中位 135~169 根、31~40% 超 200 根)。

实测效应是砍掉约 30% 信号,但分层很陡:200-600 那桶 33 笔 PF 1.01、净均
0.21bp 等于零,>600 那桶 15 笔 PF 2.34 和保留的那批一样好。所以 200 正好
切掉了无效的中间桶,放宽到 600 只会稀释。>600 样本太少不足以动参数。

样本只有本地 SOL/ETH,ETH 多数过不了 ATR 门控,故 118 笔以 SOL 为主,
ADA/DOGE/XRP 本地无数据。结论是"支持保留现状",不是"200 最优"。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ext_run(极值越过中枢边界几个 ATR)单调有效:5m 上四分位的命中率是
12.0/22.1/37.0/43.8%,PF 0.12/0.11/0.22/0.46。短延伸那批就是趋势中途被识别的,
占一半且 PF 仅 0.11。最佳组合 ext_run≥P75 且 div≥中位:命中率 47.9%、PF 0.54,
相对基准 28.7%/0.22 精度接近翻倍。

两个反直觉结果:背驰越强反而越差(div Q1 命中 17.0%/PF 0.13,Q4 37.5%/0.28),
是对 MACD 面积判据的直接证伪;趋势级数无区分力(命中率 28.5/30.6/28.6/25.5%
基本持平)。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:48:59 +08:00
jackyu66gitandCursor 6d93975e67 用 7 年长样本复核 step54:时段坐实无效,周日效应符号翻转、撤回
用户追问"你用的是长时间周期分析的?"。原答案是 1m / 18.5 个月 / 3941 笔,
而上一轮的结论是"没效果"——没效果最怕样本不够,这个追问戳到了点子上。

改用 step41 那份 5m/15m/30m 复核:2019-09 → 2026-08,2525 天,8168 笔。
口径能对上——keep = (h1_agree==1) & push 就是深色过滤,出场配置
s2_so8_k2_m48 正是实盘那套 2/3/8/2/48(cfg_name 的分批命名,scale_at 默认 3)。
唯一差异是没加 ATR≥8bp 门控,而 §3.5 实测它在这些周期上几乎不触发
(30m 0%、15m 0.16%、5m 2.6%)。

① 时段坐实无效。段间毛R极差的置换 p:5m 0.6185、15m 0.6687、30m 0.3105、
合并 0.6445,全部远离显著。1m 那个 p=0.0895 现在看清楚了,就是零分布里运气
略好的一条尾巴。

② 周末效应没复现,而且符号翻转。1m 上周末−工作日是 -0.132(p=0.0246),
长样本上 5m +0.014、15m +0.142、30m -0.006、合并 +0.041;周日从 -0.199
(p=0.0079)变成 +0.033。这不是严格的样本外复现——1m 与 5m 是不同的信号
总体——但若"周末流动性薄所以吃亏"是真的市场结构效应,它没有理由只在 1m 上
出现、在 5m 上还反号。

所以上一提交里"周日效应统计上真实"那句要撤回。本步一共跑了约 35 个分组比较
(24 小时 + 两套时段定义 + 7 个星期 + 周末/周日),冒出一个 p=0.008 恰是多重
比较的期望产物。HANDOFF 里已把该结论标为撤回并记下两条教训:报告"无效"之前
先确认样本量撑得起这个"无";在几十个分组里挑出的最显著那个,默认它是噪声,
除非能在另一个总体上复现。

实践结论不变且更硬:不加任何时间维度的过滤,mom60≥7 仍是唯一值得上的开关。

复核走 --long,复用 step41 已有 feather,未重跑采集。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:29:26 +08:00
jackyu66gitandCursor 1ffc4fbb18 时段(亚/欧/美)是噪声,周日效应真实但与 mom60 过滤不可加
用户问哪个时段更容易盈利。答案分两半:时段这个切法本身无效,换成星期几才
有东西,而那东西不该做成新过滤。

① 亚/欧/美三等分(UTC 0/8/16),毛R 1.127 / 1.006 / 1.079,段间极差 0.122,
置换检验 p=0.0895——随便把 24 小时切三份,9% 的概率能切出这么大的差。更要命
的是美盘两个时期反号:样本外 1.158(最好)→ 发现期 0.885(最差),噪声的
典型指纹。按真实开盘时刻切五段、把欧美重叠单列,结论一样。

这里不是输给 ATR 混淆。亚盘 ATR 中位确实最低(12.5 vs 14.1),本来最该是
混淆源,但控 ATR 后段间极差 0.101/0.108,和无条件的 0.122 几乎一样——时段
不是 ATR 的代理,它本来就小。

② 星期几有信号,集中在周日:毛R 0.893、胜率 66.6%、PF 2.76、余量 13.08bp,
对照周五 1.228 / 74.7% / 4.78 / 23.44。置换检验周日 p=0.0079、周末 p=0.0246,
两个时期方向一致,10 个币里 7 个周末更差(BTC 最甚 -0.336)。但幅度在发现期
塌了大半(-0.168 → -0.039)。

③ 关键在重叠。施加 step53 的 mom60<7 之后,周末差从 -0.132 缩到 -0.058、
周日从 -0.199 缩到 -0.104。重叠不在笔数上(mom60≥7 在周末占 22.9%、工作日
21.3%,几乎一样),是伤害重叠:周末真正亏钱的是那些追已走完行情的单子。
周末流动性薄,追高的代价被放大——这和 §3.391「势不能过头」是同一件事在另一
个维度上的投影。

④ 所以不加。决策表(发现期总R)显示 mom60≥7 + 周日 在 0~20bp 每一档都输给
mom60≥7 单用(5bp: 574 vs 658;10bp: 343 vs 394;15bp: 113 vs 131),叠加
只是白丢 10% 笔数。单用砍周日也要 12bp 以上才赢过等权。

这一步的价值是排除。「美盘流动性好该更赚」这种直觉很难自证伪,跑完才知道它
连随机切分都跑不赢;而顺手捞到的周日效应统计上真实,却因与已有过滤重叠而
不可加——显著和值得做是两件事,中间隔着一张决策表。

分析全部复用 step53 的 feather,未重跑采集。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:23:39 +08:00
jackyu66gitandCursor 17fe965a7c HANDOFF 跟上服务端:5.72 标题回退到旧结论,四条待办已完成
拉取后对表,文档有几处和代码脱节:

1. §5.72 标题还写「inner_ms 的 3.3 倍是争抢」,而正文经 af029bc 已更正为
   1.49x。扫标题的人会被带偏,改成 30ms 地板 + 37ms 缓存浪费 + 1.5x 争抢。

2. 「实盘信号计算改用增量」已上线(§5.7,清空十币 560→247ms),标完成,
   并把「重建不要用 init_stream」这个坑记在同一条上。

3. 「shadow_report 的 BUDGET_BP」和「live 补中枢阶梯 + ATR 门控」都已做完
   (前者改为从 lib/shadow_budget import,后者见 shadow_signal.py:11-18),
   标完成并保留当初的理由,那两条的判断过程比结论有用。

4. §5.72 新发现的两个杠杆之前只在正文里,没进待办,补上:按币绑 worker
   (省 inner_ms 三分之一)与 add_indicators 增量化,并写明二者是叠加不是
   二选一,以及后者的拦路石是 Wilder RSI 的 avg_gain/avg_loss 状态。

另外给 mom60≥7 那条待办补一句现状:live 路径的三道过滤是同向 + 阶梯 +
ATR 门控,mom60 连算都没算,要上得先在信号侧补出这个字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:23:39 +08:00
jackandCursor 881cc5e77d 补上「一个币一个仓位、账户专用」这两个没落在代码里的前提
三处都源于同一个隐含假设从未被写成代码。

① 同币并发会让 pnl_day 翻倍。blocks() 只查幂等键与三条计数闸,没有按币的
占用检查。同币开两笔时交易所净成一个仓位,于是 watch() 按 pair 判出场会让
两个 key 同时进 gone,_match_hist 给它们返回同一条历史记录,realized() 被
调两次。而 pnl_day 正是 MAX_DAY_LOSS 读的数:亏损翻倍提前停机,盈利翻倍让
闸变迟钝。sweep() 也会在第一笔截止时平掉合并后的整个仓位。

合并后的行为(一个止损、两个不同价位的止盈、超时一锅端)不是任何一版回测
建模的东西,所以在 on_signal 里跳过第二个信号,是最接近安全的近似。期望并发
0.18 笔,损失极小。另外给 _match_hist 加 positionId 独占认领,让这个不变量
在记账处本地成立,而不是依赖两百行外的检查——花钱的路径值得两道。

② positions() 返回账户全部仓位,三个调用点都没过滤。账户上任何第三方仓位
都会被 reconcile 在重启时市价平掉,而 watch() 会因该 symbol 一直在场而永不
结算,MAX_OPEN 名额泄漏、pnl_day 不再更新。加 PAIRS 过滤与 my_positions()。
残留局限记在注释里:同币上的第三方仓位仍分不出来,账户仍应专用。

③ oid_of 把非字母数字换成下划线,理由是 : 和 + 未必被接受,但调用方又拼了
"-tp" 把 - 加回去,自相矛盾。真被拒时止盈单会全部挂不上,而那条路径只告警
不停机,收益结构静默退化成「只有止损 + 超时」,且空跑验不到(dry 返回假
成功)。改用 _tp,并把字符集约束写进 oid_of 的文档。

实测:同币第二个信号被挡、别币放行、独占认领不重复计账、PAIRS 排除
PEPEUSDT、全部 clientOid 只含字母数字下划线。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 18:58:47 +08:00
jackandCursor 5ffbadb0b2 setup_symbols 不再无条件报"已设好",失败要说出来
那行"已设 N 个币为逐仓 10x"原先无条件打印,20 次调用全失败也照样这么说。
日志里写假话比不写更糟。而且这段只在真跑模式执行,空跑从没碰过它,第一次
运行就是上实盘的那一刻。

杠杆设失败是有经济后果的:仓位大小由名义额算、与杠杆无关,但保证金要求会
变。若交易所侧实际是 1x,每笔需 100 USDT 保证金,第 2、3 笔必然失败,且
可能只成一条腿、静默变成半仓——收益结构从「50% 在 3 ATR + 50% 在 8 ATR」
变成别的东西。

现在按失败项数如实报告,并推一条 Telegram。不硬性阻止启动:真正的失败会在
下单时暴露,但日志必须诚实。

实测全部成功/一项失败/全部失败三种情形。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 18:41:37 +08:00
jackandCursor 668ebe1e44 探针把 40014 判为最小权限的正常表现,并翻译 parentId/权限/白名单
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 18:36:10 +08:00
jackandCursor f62b4f62a5 加只读探针定位资金所在账户:子账户/现货/币本位/USDC 本位一次看清
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 18:29:23 +08:00
jackandCursor c513455998 余额不足时算出能开几笔,并点明半仓风险:两条腿分别下单,第二条失败会静默改掉收益结构
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 18:25:35 +08:00
jackandCursor 7435cef61f 空跑打全余额字段:逐仓下管用的是 isolatedMaxAvailable,只看 available 会误判
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 18:23:57 +08:00
jackandCursor 9562b8c598 空跑补一次带签名请求,否则密钥与白名单根本没被验到
dryrun 第 5 步声称"会真实验到密钥与白名单",但 start() 在空跑下于密钥检查
之前就 return,全程只发了 contracts() —— 那是公开端点,不验签。于是空跑会
"通过"却什么都没测到。这种假保证比不测更糟:等第一个真信号来时才暴露,而
信号那时正在过期,没有从容排查的余地。

现在空跑返回前发一次 account()(只读),失败即退出并给出码表(40018 白名单
/ 40037 key 不存在 / 40001,40009 secret,passphrase / 40099 权限)。顺带报
可用余额,并在不足 MAX_OPEN 笔并发所需保证金时告警。

两处 SystemExit 会绕过 run() 的收尾,退出前不关 aiohttp 会话,日志尾部一串
Unclosed client session 会把真正的报错顶出视野。都补上了 close()。

BITGET_PASSPHRASE 现在也接受 BITGET_API_PASSPHRASE:另两项都带 API_,只有
它不带,很容易顺手写错,而后果是"像是填了"但签名一直失败。我自己就踩了。

启动时打一行 Telegram 是否启用。配错 token 的表现原本是完全静默。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 18:17:53 +08:00
jackandCursor d797adc77e dryrun: timeout 套进 sudo 内层,否则 SIGTERM 未必传到 ssh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 18:09:29 +08:00
jackandCursor 040810d48d known_hosts 校验命令补 sudo:.ssh 是 700 chan,ubuntu 读不了
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 18:02:54 +08:00
jackandCursor d884de2c36 dryrun 的 ssh 探测适配强制命令,并分清指纹与认证两种失败
采集机那侧的 authorized_keys 用了强制命令,把这把 key 锁成只能跑 tail,
拿不到 shell。副作用是任何请求都变成 tail -F 而它永不返回,原先
`ssh <host> 'echo ok'` 的探测会永久挂住——ConnectTimeout 只管建连,不管
命令时长。改成照 ship_signals 的真实用法读流:tail -c +0 先吐出整个文件,
数行数就等于对端总线条数,之后由 timeout 收掉。

失败诊断原先一律说"装 key",但 Host key verification failed 是 known_hosts
空的,装 key 修不了,会把人引到错方向。现在分三类:指纹未确认、认证被拒、
其他,各给对应做法。指纹那条明确不建议 StrictHostKeyChecking=no——这条链路
上跑的是下单信号。

install.sh 的第 2 步补上 known_hosts:原先只说装 key,而服务跑起来会撞同一
个墙(BatchMode=yes 不允许交互确认)。

实测:强制命令下故意请求错路径,仍拿到真总线内容且 stderr 干净。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 18:01:01 +08:00
jackandCursor b6ed5c8b50 on_signal 校验字段,畸形记录只丢一条而不是停掉交易
on_signal 直接取 r["emit_ms"],总线上一条字段不全的记录就会抛 KeyError
打死 poll 任务,进而整个执行器停止交易。一行坏数据换全面停摆,代价不对等。
搬运侧已挡半行,但挡不住字段级的不全。

现在缺 key/sym/emit_ms/direction/entry_px/atr_pct 任一项即丢弃该条,记日志
并推一条 Telegram(这类事应当可见,否则只是少做几笔,统计上看不出来)。

实测:两条字段不全 + 一条非法 JSON + 一条正常,前三条分别被丢弃/被
read_all 吞掉,正常那条照常下单,进程存活。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:57:24 +08:00
jackandCursor 999ad924ad install.sh 报出模板新增的配置项,并给 --sync-env 追加
live.env 装着密钥,所以安装时刻意不覆盖。但这样一来,往 live.env.example
里加配置,已有部署会永远拿不到、且毫无提示——静默漂移,等到某个开关根本
没生效才发现。这次加 TG_TOKEN 就撞上了。

现在重复安装会 diff 两边的变量名:报模板多出的项(提示跑 --sync-env),
也报配置里已被模板删掉的项(可能已废弃)。

--sync-env 独立成一个模式而不是塞进安装流程:追加要改一个装着密钥的文件,
这种事应当由人显式发起。只追加缺的项、连它上面的注释一起,不动已有任何
一行,先备份。实测原有内容逐行未变且重复跑幂等。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:45:48 +08:00
jackandCursor 15f4aa088e 加 Telegram 通知,并修好一道死掉的闸
接 Telegram 时查出 Guard.realized() 定义了但全仓库没有调用点——pnl_day
恒为 0,MAX_DAY_LOSS 完全不生效。三条硬约束里最重要的一条是死的。

根因是止损与止盈都挂在交易所侧成交,本进程收不到通知,而 sweep 只在 48
分钟到点才查持仓。连带第二个后果:execs 条目不清,MAX_OPEN 把已出场的
仓位继续算在场,新信号被白挡到截止时刻。

补 watch() 循环(10s):持仓消失即判出场,去 history-position 取
netProfit(= pnl + 资金费 + 开平手续费)记回闸并释放名额。盈亏取交易所的
数而不自己按标记价估——估会漏掉费用且方向总偏乐观。历史未落库时留到下轮,
不会漏记。字段名按文档与官方 TS 类型的差异同时兼容 ctime/cTime。

Telegram(live/tg.py,stdlib + aiohttp):推开仓、平仓带已实现盈亏、被硬
约束挡住、报错、对账平仓、跨日结算、启动与停机。不推信号过期跳过(常态,
搬运重连会重放旧信号)与心跳,否则真事会被淹掉。启动那条兼作通道自检。
研究侧 tg_notify.send 改为复用生产的传输层,方向与 signal_bus 一致。

status.sh 增加一条判读:开过仓但 pnl 仍为 0 就是 watch() 出了问题。

实测:8 类消息渲染、_match_hist 的过早/方向不符/币不符/驼峰字段/取最近
五种情形、100 USDT 下 SOL 与 ADA 的端到端空跑(两腿等量,50/50 精确)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:30:11 +08:00
jackandCursor 3e3d579e35 文档与实现对齐:停机是 SIGTERM 且会平仓,补充崩溃/主动停机的差异
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:13:56 +08:00
jackandCursor 1642b1bbfc 补上停机处理:SIGTERM 撤挂单平仓再退,并关掉 aiohttp 会话
README 里原先写「执行器收到 SIGINT 会先平掉在场仓位再退出」,但代码里
完全没有信号处理——asyncio.run 外面连 except KeyboardInterrupt 都没有。
断言了一个不存在的行为,现在把它实现。

为什么停机要平仓而崩溃不用:崩溃后 systemd/docker 几秒内重启,reconcile
接着清掉遗留仓位,空窗期有交易所侧止损兜着。而主动停机后没人重启,仓位会
一直挂到止损或止盈,48 分钟超时腿丢了,跑的就不是回测那个出场结构。

必须显式挂 SIGTERM:docker stop 与 systemd stop 默认发的都是它,而 Python
对 SIGTERM 不抛 KeyboardInterrupt,不挂就是直接消失、没有任何清理。挂上后
systemd 单元里 KillSignal=SIGINT 那个绕法也去掉了。

主循环因异常退出时同样走停机流程,不把仓位留给已经没人管的进程。
顺带修掉 "Unclosed client session"(bitget_rest 本有 close() 但没人调,
Restart=always 下会漏 socket)。

实测:发 SIGTERM 后走完停机流程、无泄漏警告。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:12:41 +08:00
jackandCursor 335d891478 生产与研究分家:实盘执行器独立成 live/ 子树,加 AWS 部署
实盘要跑在 AWS(API key 绑了 IP 白名单),而信号在新加坡那台算。借这次
把生产从研究侧摘出来,四条具体代价里第一条已经咬过:

1. live_state.json 原先落在 research/out/,而那里 shadow_hb 会自动 rename
   归档、研究脚本会写、人也手工清过。那文件装的是 MAX_DAY_LOSS 累计与已
   处理信号键,被清掉不报错,只是两道闸静默失效。改到 LIVE_HOME。
2. 采集器十币清空 300~560ms 直接叠在信号到达执行器的延迟上。
3. 研究侧探针 OOM 过一次(14.9GB),当时若有仓位在场会连坐执行器。
4. 为读两个常量 import 研究侧 step43,把 numpy/pandas/pyarrow 拖进实盘
   进程。抽出 stdlib-only 的 live/exit_params.py,install.sh 加断言挡回归。

新增 live/ship_signals.py:AWS 侧 ssh tail 拉总线,每次重连从文件头重放
+ 按幂等键去重,断线期间的信号自愈;旧信号由 staleness 闸挡掉不补做。
带时钟倒流检测——两机时钟不同步会让那道闸静默放宽。

部署件:systemd 两单元(搬运挂了执行器仍管在场仓位的超时平仓)、
install.sh、dryrun.sh(验密钥/白名单/时钟/ssh/取整)、status.sh、README。

验证:live_exec 重构后端到端空跑,SOL 多头与 ADA 空头的止损/两级止盈/
数量取整逐项核对正确,isolated + post_only + reduceOnly 都在;搬运的去重、
重启不重复追加、脏数据跳过、断线重连重放均已测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:03:03 +08:00
jackandCursor af029bc26e 更正上一提交:争抢是 1.5x 不是 3.3x,并发现按币绑 worker 可省三分之一
上一提交的 3.3x 基线是废的:离线每次喂同一窗口,append_bar 一根没追
(实测 6 次调用 stream 2001→2001 零增长),测的只是信号链地板 30ms,
拿它比在场 inner_ms 又是一次口径不对齐——和刚撤回的 22/86 同一类错。

对齐后:在场每次平均追 2.81 根(2 worker 各存一份缓存、各自漏掉对方
处理过的根),离线同口径 67.3ms,在场 100ms → 争抢 1.49x。
顺带修正「重建占 21.5%」:真重建只有 0.31%,此前把换 worker 的小幅
回退误判成重建。

新杠杆:symbol 固定到同一 worker,每次只追 1 根,离线 67.3 → 45.0ms,
在场约省 33ms/币(inner_ms 三分之一)。心跳判定改指这一刀。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:23:02 +08:00
jackandCursor cef894376c 定位 inner_ms 的 3.3 倍:是争抢不是算法档,撤回 22/86 拆分
服务端跑 probe_inner.py 与本机对表:分档占比一致(TF_DF 两腿 68~74%,
信号链 16~21ms),此前报的「chan 构建 22ms / 信号链 86ms」是无效减法
——孤立环境的 1m append 减在场十币的 inner_ms,还漏了 5m 腿。

真实构成:inner_ms 100ms = 约 30ms 计算 × 3.3 倍争抢。逐个排除
_rebuild(1.6ms)、币种差异、周期重建(21.5%/1.5x)、批内次序(相关-0.083)
后,用外生的到达密集度确认(67→117ms),并以注入合成负载复现
(2 个满载进程 2.68x,3 个 2.86x,在场实测 2.24~3.34x)。

心跳判定不再推荐「继续压算法」,改为加核或减币。

第一版用 t_signal-inner_ms 反推并发区间得相关 0.533,是循环构造,
已废弃并在 §5.72 记下这个坑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:19:25 +08:00
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
83 changed files with 11415 additions and 430 deletions
+12
View File
@@ -46,3 +46,15 @@ research/out/*.jsonl.gz
research/out/penetration.csv
research/out/shadow_*.csv
research/out/run_meta_*.json
# Telegram 凭据。**不要提交**
research/live/deploy/tg.env
research/.tg.env
# 生产状态与信号总线。刻意放在仓库外(LIVE_HOME / BUS_DIR),这几条只防
# 有人把它们指回仓库里:里面是日亏损累计与已处理信号键,被 git clean
# 清掉等于两道闸静默失忆
/live/deploy/live.env
live_state.json
live_trades.jsonl
signals_live.jsonl
+52 -21
View File
@@ -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):
+23
View File
@@ -43,6 +43,29 @@ class ChanSEG():
self.macd_hist = macd_hist
def set_macd_div(self, macd_div):
self.macd_div = macd_div
def cal_macdhist(self):
# 线段面积 = 同向笔 MACD 柱面积之和(与笔面积口径一致)
acc = 0.0
seg_dir_name = getattr(self.dir, 'name', None)
for bi in self.bi_list:
if bi is None:
continue
if getattr(getattr(bi, 'dir', None), 'name', None) != seg_dir_name:
continue
acc += float(bi.macd_hist or 0)
self.macd_hist = acc
return acc
def cal_macd_div(self):
# 与前一个同向线段比面积:seg.pre 是反向邻段,pre.pre 才是同向
self.macd_div = 0.0
prev = self.pre.pre if self.pre and self.pre.pre else None
if prev is None:
return 0.0
prev_hist = float(prev.macd_hist or 0)
if prev_hist == 0:
return 0.0
self.macd_div = float(self.macd_hist or 0) / prev_hist
return self.macd_div
def set_end_bi(self, bi: ChanBI, sure_bi: ChanBI):
self.end_bi = bi
if bi and bi.is_sure:
+11 -2
View File
@@ -668,13 +668,22 @@ class BiBuilderMixin:
return bi_list
def check_top_fx(self, last_bottom, klc):
if (last_bottom.high > klc.pre.low or last_bottom.high > klc.next.low) and (klc.index - last_bottom.index < 100):
#严格笔
#last_bottom_high = max(last_bottom.high, last_bottom.pre.high, last_bottom.next.high)
#缠论原著笔
last_bottom_high = last_bottom.high
if (last_bottom_high > klc.pre.low or last_bottom_high > klc.next.low) and (klc.index - last_bottom.index < 100):
#print(klc.end_time, "check_top_fx False")
return False
return True
def check_bottom_fx(self, last_top, klc):
if (last_top.low < klc.pre.high or last_top.low < klc.next.high) and (klc.index - last_top.index < 100):
#严格笔
#last_top_low = min(last_top.low, last_top.pre.low, last_top.next.low)
#缠论原著笔
last_top_low = last_top.low
if (last_top_low < klc.pre.high or last_top_low < klc.next.high) and (klc.index - last_top.index < 100):
return False
return True
# 线段内的中枢
+45 -36
View File
@@ -54,8 +54,14 @@ class IndicatorsBuilderMixin:
return None
def add_indicators(self, df):
fast = 26
slow = 52
"""算指标并一次性挂到 df 上。
这里不逐列 `df['x'] = ...`:那样每一列都触发一次 BlockManager 插入,
30 多列的开销比全部 TA 计算本身还大(2001 行实测 TA 合计 2.5ms
逐列赋值 3.6ms)。增量路径每根都要走一遍,这笔开销是白付的。
"""
fast = 12
slow = 26
period = 9
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
@@ -75,40 +81,43 @@ class IndicatorsBuilderMixin:
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'])
df['bb2633upper'] = bb2633['upperband']
df['bb2633lower'] = bb2633['lowerband']
df['bbp2633'] = bbp2633
df['bb2633middle'] = bb2633['middleband']
df['atr'] = ta.ATR(df, timeperiod=14)
df['bbup365'] = bb365['upperband']
df['bblow365'] = bb365['lowerband']
df['bbp365'] = bbp365
df['bbup120'] = bb120['upperband']
df['bblow120'] = bb120['lowerband']
df['bbp120'] = bbp120
df['bbup30'] = bb30['upperband']
df['bblow30'] = bb30['lowerband']
df['bbmiddle30'] = bb30_middle # 添加bb30中轨
df['bbp30'] = bbp30
df['bbup302'] = bb302['upperband']
df['bblow302'] = bb302['lowerband']
df['bbp302'] = bbp302
df['macd'] = macd['macd']
df['macdsignal'] = macd['macdsignal']
df['macdhist'] = macd['macdhist']
df['ema5'] = ta.EMA(df, timeperiod=5)
df['ema10'] = ta.EMA(df, timeperiod=10)
df['ema24'] = ta.EMA(df, timeperiod=24)
df['ema52'] = ta.EMA(df, timeperiod=52)
df['ema104'] = ta.EMA(df, timeperiod=104)
df['ema156'] = ta.EMA(df, timeperiod=156)
df['ema208'] = ta.EMA(df, timeperiod=208)
df['ema26'] = ta.EMA(df, timeperiod=26)
df['ema13'] = ta.EMA(df, timeperiod=13)
df['ema7'] = ta.EMA(df, timeperiod=7)
df['rsi'] = ta.RSI(df, timeperiod=14)
df['volume_ratio'] = self.cal_volume_ratio(df)
return df
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)
+24 -14
View File
@@ -128,23 +128,28 @@ class KlineBuilderMixin:
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)
p = ""
for klu in klu_list:
p += klu.to_string()
#print(p)
def cal_volume_ratio(self, dataframe, window=10):
df = dataframe.copy()
# 计算过去N根K线的平均成交量
df['avg_volume'] = df['volume'].rolling(window=window).mean()
# 计算量比
df['volume_ratio'] = df['volume'] / df['avg_volume']
# 填充缺失值(前N根K线)
df['volume_ratio'] = df['volume_ratio'].fillna(1.0)
return df['volume_ratio']
"""当根量 / 前 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 链。
@@ -249,7 +254,12 @@ class KlineBuilderMixin:
for klu in klu_list:
self._push_klu_into_klc_list(klc_list, klu, last_klu)
last_klu = klu
klc_list = self.cal_trend(klc_list)
# 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
+245
View File
@@ -0,0 +1,245 @@
"""Bitget v2 合约 REST 的最小客户端,只覆盖实盘执行要用的几个端点。
## 为什么不用 Hummingbot 下单
Hummingbot 的 Bitget 连接器只暴露 LIMIT / LIMIT_MAKER / MARKET,没有触发单。
于是 `PositionExecutor` 的止损只能在本地控制循环里盯价、触发时才发市价单——
**进程一死仓位就是裸的**。
而交易所本身完全支持:`place-order` 有 `presetStopLossPrice`,下单时就把止损
挂到服务端。所以整个结构变成两个调用,止损从入场那一刻起就不依赖我们的进程
存活。绕过连接器不是图省事,是为了消掉一整类故障。
## 止盈为什么不用 presetStopSurplusPrice
它触发后按**市价**执行。而成本模型里止盈是 maker——那 60% 的出场不吃滑点、
按 maker 费率计(见 `lib/shadow_budget.LEG_IS_TAKER`)。用 preset 会让这部分
变成 taker,预算模型就不成立了。所以止盈单独挂 `post_only` 的 reduce-only
限价单。
止损反过来:必须是市价。stop-limit 在急跌里可能不成交,损失远大于省下的费。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
BASE = "https://api.bitget.com"
PRODUCT = "usdt-futures"
MARGIN_COIN = "USDT"
class BitgetError(RuntimeError):
def __init__(self, code: str, msg: str, path: str):
super().__init__(f"{path} → [{code}] {msg}")
self.code, self.msg = code, msg
class Bitget:
def __init__(self, key: str = "", secret: str = "", passphrase: str = "",
dry: bool = False):
self.key = key or os.environ.get("BITGET_API_KEY", "")
self.secret = secret or os.environ.get("BITGET_API_SECRET", "")
# 两个名字都收:另外两项是 BITGET_API_KEY / BITGET_API_SECRET
# 这一项却没有 API_,很容易顺手写成 BITGET_API_PASSPHRASE。写错的
# 后果是"密钥像是填了"但签名一直失败,排查起来很绕
self.passphrase = (passphrase
or os.environ.get("BITGET_PASSPHRASE", "")
or os.environ.get("BITGET_API_PASSPHRASE", ""))
self.dry = dry
self._sess = None
def _sign(self, ts: str, method: str, path: str, body: str) -> str:
msg = f"{ts}{method.upper()}{path}{body}"
return base64.b64encode(hmac.new(
self.secret.encode(), msg.encode(), hashlib.sha256).digest()
).decode()
async def _req(self, method: str, path: str, params: dict | None = None,
body: dict | None = None) -> dict:
import aiohttp
if self._sess is None:
self._sess = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=15))
qs = ""
if params:
qs = "?" + "&".join(f"{k}={v}" for k, v in sorted(params.items()))
payload = json.dumps(body) if body else ""
ts = str(int(time.time() * 1000))
headers = {
"ACCESS-KEY": self.key,
"ACCESS-SIGN": self._sign(ts, method, path + qs, payload),
"ACCESS-PASSPHRASE": self.passphrase,
"ACCESS-TIMESTAMP": ts,
"Content-Type": "application/json",
"locale": "en-US",
}
async with self._sess.request(method, BASE + path + qs,
headers=headers,
data=payload or None) as r:
d = await r.json()
if str(d.get("code")) != "00000":
raise BitgetError(str(d.get("code")), str(d.get("msg")), path)
return d.get("data")
async def close(self) -> None:
if self._sess is not None:
await self._sess.close()
self._sess = None
# ── 只读 ──────────────────────────────────────────────────────
async def contracts(self) -> dict:
"""合约规则。用于数量步长与价格 tick。"""
d = await self._req("GET", "/api/v2/mix/market/contracts",
{"productType": PRODUCT})
return {c["symbol"]: c for c in d}
async def positions(self) -> list:
d = await self._req("GET", "/api/v2/mix/position/all-position",
{"productType": PRODUCT,
"marginCoin": MARGIN_COIN})
return [p for p in (d or []) if float(p.get("total") or 0) != 0]
async def history_positions(self, start_ms: int | None = None,
limit: int = 100) -> list:
"""已平仓位,用来取**已实现盈亏**。
为什么必须问交易所而不是自己算:止损与止盈都挂在交易所侧成交,本进程
看不到成交价;而且要算准还得含手续费与资金费。这个端点的 `netProfit`
已经是 `pnl + totalFunding + openFee + closeFee`,正是日亏损上限该用
的数。自己按标记价估会把费用漏掉,方向还总是偏乐观。
返回形状按文档是 `data.list`,但也见过直接给数组的写法,两种都收。
时间字段文档写 `ctime/utime`,官方 TS 类型写 `cTime/uTime`,同样都读。
"""
p: dict = {"productType": PRODUCT, "limit": str(limit)}
if start_ms:
p["startTime"] = str(int(start_ms))
d = await self._req("GET", "/api/v2/mix/position/history-position", p)
if isinstance(d, dict):
return list(d.get("list") or [])
return list(d or [])
async def fee_rate(self, symbol: str) -> dict:
"""账户在该合约上的**实际**费率档。
这一项决定 ATR 门控阈值(约 5 + 1.1×taker_bp),进而决定可交易币池。
接口的合约默认档是 VIP0,不是账户档,必须问这个端点。
"""
return await self._req("GET", "/api/v2/mix/market/query-position-lever",
{"symbol": symbol, "productType": PRODUCT})
async def account(self) -> dict:
return await self._req("GET", "/api/v2/mix/account/account",
{"symbol": "BTCUSDT", "productType": PRODUCT,
"marginCoin": MARGIN_COIN})
# ── 写 ────────────────────────────────────────────────────────
async def set_leverage(self, symbol: str, lev: int,
hold_side: str | None = None) -> dict:
body = {"symbol": symbol, "productType": PRODUCT,
"marginCoin": MARGIN_COIN, "leverage": str(lev)}
if hold_side:
body["holdSide"] = hold_side
return await self._req("POST", "/api/v2/mix/account/set-leverage",
body=body)
async def set_margin_mode(self, symbol: str,
mode: str = "isolated") -> dict:
return await self._req("POST", "/api/v2/mix/account/set-margin-mode",
body={"symbol": symbol, "productType": PRODUCT,
"marginCoin": MARGIN_COIN,
"marginMode": mode})
async def set_position_mode(self, mode: str = "one_way_mode") -> dict:
"""单向 / 双向持仓。**按 productType 生效,不是按 symbol。**
必须显式设,因为它决定下单体的语法,两者不匹配会被整体拒单:
单向:side=buy/sell**不带** tradeSide;平仓用 reduceOnly=YES
双向:side + tradeSide=open/closereduceOnly 在这个模式下无效
实盘上曾因为带着 tradeSide 打到单向账户,连续 8 次下单全被 40774 拒掉
(4 个信号 × 2 条腿),而链路其余部分完全正常。
我们永不同时持有两个方向,所以单向是对的模式;且 reduceOnly 只在单向
下可用,而出场腿依赖它防止反手开出反向仓。
交易所侧有持仓或挂单时切换会失败——所以调用点放在 reconcile 之后。
"""
return await self._req("POST", "/api/v2/mix/account/set-position-mode",
body={"productType": PRODUCT, "posMode": mode})
async def entry_with_stop(self, symbol: str, side: str, size: str,
stop_px: str, client_oid: str) -> dict:
"""市价入场,**同时**把止损挂到服务端。
`presetStopLossPrice` 触发后按市价执行,这正是成本模型要的(止损是
taker)。`clientOid` 给交易所级幂等——重发同一个 oid 会被拒,比本地
去重可靠,因为「已发出但没收到回复」这种情况本地判不了。
**不带 `tradeSide`**:账户是单向持仓,带了会被 40774 整体拒单。
"""
body = {"symbol": symbol, "productType": PRODUCT,
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
"size": size, "side": side,
"orderType": "market", "clientOid": client_oid,
"presetStopLossPrice": stop_px}
if self.dry:
print(f" [dry] 入场+止损 {body}", flush=True)
return {"orderId": "dry", "clientOid": client_oid}
return await self._req("POST", "/api/v2/mix/order/place-order",
body=body)
async def tp_limit(self, symbol: str, side: str, size: str, px: str,
client_oid: str) -> dict:
"""挂 maker 止盈。
`side` 传的是**平仓方向**(多头止盈是 sell)。`post_only` 保证是 maker
成本模型里止盈那 60% 按 maker 费率计且不吃滑点,用 taker 会破坏预算。
单向持仓下平仓的写法是 `side` 取反 + `reduceOnly=YES`**不带**
`tradeSide`。`reduceOnly` 也正好只在单向模式下有效,它防止反手开出
一个反向仓。
"""
body = {"symbol": symbol, "productType": PRODUCT,
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
"size": size, "side": side,
"orderType": "limit", "price": px, "force": "post_only",
"reduceOnly": "YES", "clientOid": client_oid}
if self.dry:
print(f" [dry] 止盈限价 {body}", flush=True)
return {"orderId": "dry", "clientOid": client_oid}
return await self._req("POST", "/api/v2/mix/order/place-order",
body=body)
async def close_market(self, symbol: str, hold_side: str,
size: str, client_oid: str) -> dict:
"""市价平(超时腿与对账用)。
同 tp_limit:单向持仓下是 `side` 取反 + `reduceOnly`,不带 `tradeSide`。
"""
side = "sell" if hold_side == "long" else "buy"
body = {"symbol": symbol, "productType": PRODUCT,
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
"size": size, "side": side,
"orderType": "market", "reduceOnly": "YES",
"clientOid": client_oid}
if self.dry:
print(f" [dry] 市价平 {body}", flush=True)
return {"orderId": "dry"}
return await self._req("POST", "/api/v2/mix/order/place-order",
body=body)
async def cancel_all(self, symbol: str) -> dict:
body = {"symbol": symbol, "productType": PRODUCT,
"marginCoin": MARGIN_COIN}
if self.dry:
print(f" [dry] 撤全部挂单 {symbol}", flush=True)
return {}
return await self._req("POST", "/api/v2/mix/order/cancel-all-orders",
body=body)
+170
View File
@@ -0,0 +1,170 @@
# 小额实盘执行器 · 部署
## 为什么是两台机
Bitget 的 API key 绑了 IP 白名单,只能从 AWS 那台发单;信号是新加坡那台采集器
算出来的。于是分工固定成:
```
新加坡(采集/研究机) AWS(生产机)
shadow_hb.py 算信号 ship_signals.py 拉总线
└→ ~/chan-live/state/ └→ /var/lib/chan-live/state/
signals_live.jsonl ──ssh tail──→ signals_live.jsonl
live_exec.py 读总线 → Bitget REST
```
**生产机上不装采集侧的任何东西**Docker / Hummingbot / pandas / chanlun)。
理由不是洁癖,是四条具体代价:
1. `live_state.json` 原先落在 `research/out/`,而那个目录 `shadow_hb.py` 会在
CSV 表头变化时自动 rename 归档、研究脚本会写、人也会手工清数据。那个文件装
的是 `MAX_DAY_LOSS` 累计与已处理信号键,**被清掉不报错,只是两道闸静默
失效**。现在改到 `/var/lib/chan-live`
2. 采集器十币清空 300~560ms,直接叠在信号到达执行器的延迟上。
3. 研究侧的探针 OOM 过一次(14.9 GB、负载 12)。当时若有仓位在场,执行器会被
一起杀掉,只剩交易所侧止损兜着。
4. 依赖面:执行器只需标准库 + `aiohttp`。原先为读两个常量 import 研究侧的
`step43`,把 numpy/pandas/pyarrow 全拖进实盘进程。
`install.sh` 里有一条断言会真的挡住第 4 条回归。
## 机器要求
CPU 无所谓(执行器几乎不算东西,信号 6.8 个/天)。要的是:
- 出口 IP 固定,且已加进 Bitget 该 key 的白名单
- `chrony` 能同步。**这一条是硬要求**`LIVE_STALE_S` 那道闸靠两机时钟一致才
有意义,采集机时钟快 5 分钟就等于把闸放宽 5 分钟,一个早已失效的参考价会被
当成新鲜的照做
- 能 ssh 到采集机(拉总线用)
## 一、生产机(AWS
```bash
# 1. 取代码。只需要 live/ 这一个子树,但整仓克隆更省事
git clone -b chan <repo> /tmp/chan && cd /tmp/chan
sudo ./live/deploy/install.sh
# 或让它自己克隆:sudo REPO=<repo> ./live/deploy/install.sh
# 2. 填密钥与参数
sudo vi /etc/chan-live/live.env
```
`live.env` 里必须改的四项:`BITGET_API_KEY` / `SECRET` / `PASSPHRASE` /
`SHIP_FROM`。密钥权限**只勾只读 + 交易,不要勾提币**。
```bash
# 3. 装拉总线用的 ssh key
sudo -u chan ssh-keygen -t ed25519 -N '' \
-f /var/lib/chan-live/home/.ssh/id_ed25519
sudo cat /var/lib/chan-live/home/.ssh/id_ed25519.pub
# 把这一行加到采集机的 ~/.ssh/authorized_keys
# 4. 空跑验全链(不下真单)
sudo ./live/deploy/dryrun.sh
```
`dryrun.sh` 验的是那些"上线才暴露、且暴露方式是花钱"的环节:密钥能不能用、
IP 白名单对不对、chrony 同步没有、ssh 通不通、对端总线有没有信号、数量与价位
取整合不合交易所规则。**不要跳过。**
```bash
# 5. 真跑
sudo systemctl enable --now chan-live-ship chan-live-exec
./live/deploy/status.sh
```
## 二、采集机(新加坡)
采集器要重启一次才会开始往总线写——`signal_bus.emit` 是后加的,跑着的进程没有
加载。重启会丢已采的几分钟,数据本身不受影响(CSV 是追加的)。
```bash
cd <repo> && git pull
docker stop shadow && docker rm shadow
SHADOW_SITE=sg-tencent SYMS=BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC \
bash research/live/deploy/start.sh
```
`start.sh` 会把 `$HOME/chan-live/state` 挂进容器成 `/bus`,总线落在
`$HOME/chan-live/state/signals_live.jsonl`。**总线刻意放在仓库外**,因为它是
交给另一台机的交接点,而仓库会被 git 动。
确认在写:
```bash
ls -la ~/chan-live/state/
# 等一个信号(6.8 个/天,可能要等几小时)
tail -f ~/chan-live/state/signals_live.jsonl
```
## 日常
```bash
./live/deploy/status.sh # 一屏体检
journalctl -u chan-live-exec -f # 执行器日志
journalctl -u chan-live-ship -f # 搬运日志
```
**怎么判健康:** 信号 6.8 个/天,所以"很久没有新信号"是正常的,不能当健康
指标。要看的是 `chan-live-ship` 的心跳(每 5 分钟一条),里面报 ssh 在线时长与
重连次数。管道死了但进程还活着是这里最危险的状态——`ServerAliveInterval=15`
负责让它变成一次可见的断开。
**Telegram**`live.env` 里填 `TG_TOKEN` / `TG_CHAT` 就开。启动时会推一条
"执行器启动",兼作通道自检——配错了当场就知道,而不是等几小时后第一个真信号
来时才发现。之后每小时一条在线(`TG_HB_MIN`,默认 60),带建仓数、当日盈亏
和搬运 ssh 新鲜度——执行器活着不代表上游还在投信号。推开仓、平仓(带已实现
盈亏)、被硬约束挡住、报错、对账平仓、跨日结算;不推信号过期跳过(常态)和
5 分钟日志心跳。约 55 条/天上限。
## 停机与回滚
```bash
# 停新开仓,但保留在场仓位的管理(48 分钟超时平仓在执行器进程里)
sudo systemctl stop chan-live-ship
# 全停。执行器收到 SIGTERM 会**撤挂单 + 市价平掉在场仓位**再退出
# (内部平仓上限 60s,systemd 给了 90s 停机窗口)
sudo systemctl stop chan-live-exec
# 代码回滚
cd /opt/chan && sudo git reset --hard <sha> && sudo systemctl restart chan-live-exec
```
⚠️ **状态目录 `/var/lib/chan-live` 不要跟着回滚。** 它存的是当日计数与已处理
信号键;清掉等于日上限归零、且可能重开已经做过的仓。
## 故障处理
| 症状 | 大概率原因 |
|---|---|
| 启动即 `40018` / 签名错 | 出口 IP 不在白名单,或密钥抄错。`curl https://api.ipify.org` 对一下 |
| 搬运日志 `Permission denied (publickey)` | 第 3 步的 pubkey 没加到采集机 |
| 搬运在线但一直没信号 | 采集机没重启过(`signal_bus.emit` 没加载),或对端总线路径不对 |
| Telegram 在线报「读不到搬运」 | 搬运没起,或还是没落 `ship_alive.json` 的旧版本,两边一起重启 |
| Telegram 在线报「ssh 已断开」 | 采集机 ssh 断了,搬运在重连。看 `chan-live-ship` 日志 |
| 日志 `⛔ 时间倒流 Xs` | 两机时钟不同步,**staleness 闸已不可信**。查两边 `chronyc tracking` |
| 信号收到但都被跳过 | `age > LIVE_STALE_S`。看是搬运慢还是时钟偏;也可能是重连重放的旧信号(这种跳过是对的) |
| `systemctl status` 显示 start-limit-hit | 5 分钟内重启 5 次,systemd 停手了。先看 journal 找真因,再 `systemctl reset-failed` |
| 执行器起不来,报缺 numpy/pandas | 有人给生产侧加了研究侧的 import。`install.sh` 的依赖断言就是挡这个 |
## 已知的退化边界
- **进程死掉不会变成裸仓。** 止损与止盈都挂在交易所侧(`presetStopLossPrice`
与 post-only reduce-only 限价单),只有 48 分钟超时平仓在本进程。所以进程死
掉的后果是持仓超过 48 根,不是失去保护。
- **崩溃与主动停机的处理不同,是刻意的。** 崩溃后 systemd 几秒内重启,
`reconcile` 接着撤挂单 + 平掉遗留仓位,空窗期由交易所侧止损兜着。主动
`systemctl stop` 则在退出前就平掉——因为停机后没人重启,仓位会一直挂到止损
或止盈,超时腿丢了就不是回测那个出场结构了。
- **断线超过 20s 就等于漏掉那期间的信号。** 重连会把总线重放上来,但旧信号会被
staleness 闸挡掉。这是对的——参考成交价是次根开盘价,过了就不是回测那个价。
- **重启时会平掉交易所上已有的仓位**(`reconcile`)。接管要重建入场价、ATR、
剩余半仓状态和已过根数,任一项猜错就跑成另一个收益结构,所以选择平掉。
- **日亏损上限依赖 `watch()` 循环**。止损与止盈在交易所侧成交,本进程收不到
通知,所以有个 10s 轮询去 `history-position``netProfit`(含手续费与资金
费)记回闸。这个循环停了,`pnl_day` 会恒为 0`MAX_DAY_LOSS` 静默失效。
心跳里会打 `当日 PnL x/-20`,值一直是 0.00 而又确实有平仓,就是它出了问题。
- **平仓后 `MAX_OPEN` 名额由 `watch()` 释放**,不是立刻。最坏延迟 10s。若
历史记录还没落库,会等下一轮,日志里打"历史未就绪,下轮再结算"。
+49
View File
@@ -0,0 +1,49 @@
[Unit]
Description=chan 小额实盘执行器(读信号总线,直接调 Bitget REST
# 搬运挂了执行器仍要活着——它得继续管在场仓位的 48 分钟超时平仓。
# 所以这里只写 Wants(弱依赖),不写 Requires
Wants=network-online.target chan-live-ship.service
After=network-online.target chan-live-ship.service
# 频繁重启说明有真问题,别让它无限打交易所。5 分钟内起 5 次就停下等人。
# 注意这两项在 systemd 229+ 属于 [Unit],写在 [Service] 里会被忽略
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
User=chan
Group=chan
WorkingDirectory=/opt/chan
# 密钥与参数在这个文件里,权限必须 600。用 EnvironmentFile 而不是
# Environment=,后者会出现在 `systemctl show` 的输出里
EnvironmentFile=/etc/chan-live/live.env
ExecStart=/opt/chan/.venv/bin/python /opt/chan/live/live_exec.py
Restart=always
RestartSec=5
# 收到 stop 时给足时间:执行器接到 SIGTERM 会撤挂单 + 平掉在场仓位再退出
# live_exec.shutdown(),内部平仓上限 60s)。默认的 90s 停机窗口留了余量。
# 不需要 KillSignal=SIGINT——SIGTERM 已在代码里显式挂了处理器
TimeoutStopSec=90
StandardOutput=journal
StandardError=journal
SyslogIdentifier=chan-live-exec
# ── 收紧权限 ──────────────────────────────────────────────────────
# 生产进程只需要读 /opt/chan 和读写状态目录,别的一概不给。这几条很廉价,
# 但真出了远程代码执行,爆炸半径小很多——而这个进程手里有交易权限的密钥
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/chan-live
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryMax=512M
[Install]
WantedBy=multi-user.target
+40
View File
@@ -0,0 +1,40 @@
[Unit]
Description=chan 信号搬运(把采集机的总线拉到本机)
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Type=simple
User=chan
Group=chan
WorkingDirectory=/opt/chan
EnvironmentFile=/etc/chan-live/live.env
# SHIP_FROM 在 live.env 里给,形如 sg-collector 或 user@1.2.3.4
ExecStart=/opt/chan/.venv/bin/python /opt/chan/live/ship_signals.py \
--from ${SHIP_FROM} --remote-bus ${SHIP_REMOTE_BUS}
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=chan-live-ship
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
# chan 用户的家目录放在 /var/lib/chan-live/home,只装拉总线用的那一把 ssh
# key。这样 ProtectHome=true 挡住 /home 与 /root 的同时,ssh 仍能读到
# ~/.ssh(它在 /var/lib 下,不受 ProtectHome 影响),也能写 known_hosts
ProtectHome=true
Environment=HOME=/var/lib/chan-live/home
ReadWritePaths=/var/lib/chan-live
ProtectKernelTunables=true
ProtectKernelModules=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryMax=256M
[Install]
WantedBy=multi-user.target
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# 空跑验全链:真连交易所读规则/持仓,**不下任何真单**。
#
# 上真单之前必须过这一步。它验的是那些"上线才会暴露、且暴露方式是花钱"的
# 环节:密钥能不能用、IP 白名单对不对、时钟同不同步、搬运通不通、
# 数量与价位取整合不合交易所规则。
set -euo pipefail
APP=/opt/chan
CONF=/etc/chan-live/live.env
STATE=/var/lib/chan-live
USER_NAME=chan
SECS="${SECS:-90}"
die() { echo "$*" >&2; exit 1; }
ok() { echo "$*"; }
[[ $EUID -eq 0 ]] || die "要 rootsudo $0"
[[ -f "$CONF" ]] || die "$CONF,先跑 install.sh"
set -a; . "$CONF"; set +a
echo "── 1. 配置自检 ──"
for v in BITGET_API_KEY BITGET_API_SECRET BITGET_PASSPHRASE SHIP_FROM; do
[[ -n "${!v:-}" ]] || die "$CONF$v 还是空的"
done
ok "密钥三项与 SHIP_FROM 都已填"
[[ "$LIVE_HOME" != /opt/chan* ]] || die "LIVE_HOME 不能指到仓库里(会被 git 清掉)"
ok "LIVE_HOME=$LIVE_HOME 在仓库外"
echo "── 2. 时钟 ──"
# staleness 闸靠两机时钟一致才有意义。这里只能验本机;跨机偏差由
# ship_signals 在收到信号时报「时间倒流」
if command -v chronyc >/dev/null; then
chronyc tracking | grep -E "Reference ID|System time|Leap status" | sed 's/^/ /'
src="$(chronyc tracking | awk '/Reference ID/{print $NF}')"
[[ "$src" != "()" && -n "$src" ]] || die "chrony 还没同步上,等一会再跑"
ok "chrony 已同步"
else
die "没装 chrony。staleness 闸不可信,先 apt install chrony"
fi
echo "── 3. 出口 IP 是否在白名单内 ──"
myip="$(curl -s --max-time 10 https://api.ipify.org || true)"
[[ -n "$myip" ]] && echo " 本机出口 IP$myip" || echo " ⚠ 取不到出口 IP"
echo " 对照 Bitget 后台该 key 的 IP 白名单,不一致下面会报 40018 之类"
echo "── 4. 能否 ssh 到采集机 ──"
# 不能用 ssh <host> 'echo ok' 来试:采集机那侧的 authorized_keys 用了强制命令
# (把这把 key 锁成只能跑 tail,拿不到 shell),任何请求都会变成 tail -F,
# 而它**永不返回**——ConnectTimeout 只管建连不管命令时长,测试会永久挂住。
# 所以照 ship_signals 的真实用法读流:tail -c +0 会先把整个文件吐出来,
# 数一下就等于对端总线的条数,之后它挂着等新内容,由 timeout 收掉。
rb="${SHIP_REMOTE_BUS:-\$HOME/chan-live/state/signals_live.jsonl}"
err="$(mktemp)"; outf="$(mktemp)"
# timeout 要套在 sudo **里面**:套外面时 SIGTERM 只到 sudo,未必传给 ssh
# 于是该被收掉的 tail 会继续挂着
sudo -u "$USER_NAME" timeout 12 ssh -T -o BatchMode=yes -o ConnectTimeout=10 \
"$SHIP_FROM" "tail -c +0 -F $rb" >"$outf" 2>"$err" || true
n="$(wc -l < "$outf")"
if grep -qi "Host key verification failed" "$err"; then
fp="(在采集机上跑 ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub 拿)"
rm -f "$err" "$outf"
die "主机指纹没确认过。这不是 key 的问题,装 key 也修不了。
$USER_NAME 的 known_hosts 是空的,而 BatchMode=yes 不允许交互确认。
**不要**用 StrictHostKeyChecking=no 糊过去,那等于放弃中间人防护。
正确做法:在采集机上读出权威指纹 $fp
再在这台上写入并核对:
sudo -u $USER_NAME ssh-keyscan -t ed25519 <采集机IP> \\
| sudo -u $USER_NAME tee -a $STATE/home/.ssh/known_hosts
sudo -u $USER_NAME ssh-keygen -lf $STATE/home/.ssh/known_hosts"
elif grep -qiE "Permission denied|publickey" "$err"; then
rm -f "$err" "$outf"
die "认证被拒。指纹是通的,是 key 没装到采集机上:
sudo -u $USER_NAME ssh-keygen -t ed25519 -N '' -f $STATE/home/.ssh/id_ed25519
再把 $STATE/home/.ssh/id_ed25519.pub 加到采集机的 authorized_keys"
elif [[ -s "$err" ]] && ! grep -q "^" "$outf" 2>/dev/null; then
msg="$(head -3 "$err")"; rm -f "$err" "$outf"
die "ssh $SHIP_FROM 不通:$msg"
else
ok "ssh $SHIP_FROM"
echo " 对端总线现有 $n 条信号"
[[ "$n" -gt 0 ]] || echo " ⚠ 对端总线是空的。信号 6.8 个/天,刚重启过就是空的很正常;但要确认采集机接了总线"
rm -f "$err" "$outf"
fi
echo "── 5. 执行器空跑 ${SECS}s(真连交易所,不下单)──"
# --dry-run 下只读不写:下单一律只打印。合约规则那个端点是**公开**的、不验签,
# 所以空跑里专门补了一次带签名的 account() —— 否则这一步会"通过"却根本没测到
# 密钥与 IP 白名单,等第一个真信号来时才暴露,而那时信号正在过期
# 让 chan 自己 source 配置($CONF 是 640 root:chan,它读得到)。
# 不用 `env "$(grep ...)"` 那种拼法:值里有空格就会被切开
set +e
sudo -u "$USER_NAME" bash -c \
"set -a; . '$CONF'; set +a; exec timeout $SECS \
'$APP/.venv/bin/python' '$APP/live/live_exec.py' --dry-run"
rc=$?
set -e
# timeout 到点是 124,属于预期
[[ $rc -eq 124 || $rc -eq 0 ]] || die "空跑退出码 $rc,看上面报错"
ok "空跑没有报错退出"
echo
echo "空跑过了。真跑:"
echo " sudo systemctl enable --now chan-live-ship chan-live-exec"
echo " $APP/live/deploy/status.sh"
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
# 在生产机(有 Bitget API key 白名单的那台)上装实盘执行器。
#
# 只装执行侧:标准库 + aiohttp。**不装** Docker / Hummingbot / pandas /
# chanlun 引擎——那些是采集与研究侧的依赖,理由见 live/live_exec.py 文件头。
#
# sudo ./install.sh # 从当前 checkout 安装
# sudo REPO=git@host:jack/chan.git ./install.sh # 或指定远程克隆
#
# 幂等:重复跑只会更新代码与依赖,不动 /etc/chan-live/live.env 和状态目录。
set -euo pipefail
APP=/opt/chan
STATE=/var/lib/chan-live
CONF=/etc/chan-live
USER_NAME=chan
BRANCH="${BRANCH:-chan}"
REPO="${REPO:-}"
die() { echo "$*" >&2; exit 1; }
say() { echo " $*"; }
[[ $EUID -eq 0 ]] || die "要 rootsudo $0"
# ── --sync-env:把模板新增的项追加到已有配置 ────────────────────────
# 独立成一个模式而不是塞进安装流程:追加会改一个装着密钥的文件,这种事应当
# 由你显式发起。只追加缺的项(连它上面的注释一起),**不动**已有任何一行。
if [[ "${1:-}" == "--sync-env" ]]; then
EX="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/live.env.example"
[[ -f "$EX" ]] || die "找不到模板 $EX"
[[ -f "$CONF/live.env" ]] || die "$CONF/live.env 还不存在,先跑一次 sudo $0"
cp -a "$CONF/live.env" "$CONF/live.env.bak.$(date +%Y%m%d%H%M%S)"
python3 - "$EX" "$CONF/live.env" <<'PY'
import re
import sys
ex_path, live_path = sys.argv[1], sys.argv[2]
ex = open(ex_path, encoding="utf-8").read().splitlines()
live = open(live_path, encoding="utf-8").read()
KEY = re.compile(r"^([A-Z_][A-Z0-9_]*)=")
have = set(KEY.match(x).group(1) for x in live.splitlines() if KEY.match(x))
add, block = [], []
for line in ex:
if KEY.match(line):
k = KEY.match(line).group(1)
if k not in have:
add += block + [line]
block = []
elif line.startswith("#") or (not line.strip() and block):
block.append(line)
else:
block = []
if not add:
print(" 配置已是最新,无需追加")
raise SystemExit(0)
with open(live_path, "a", encoding="utf-8") as f:
f.write("\n\n# ── 以下由 install.sh --sync-env 追加 ──\n")
f.write("\n".join(add) + "\n")
n = sum(1 for x in add if KEY.match(x))
print(f" 追加了 {n} 项:" +
" ".join(KEY.match(x).group(1) for x in add if KEY.match(x)))
PY
say "原文件已备份为 $CONF/live.env.bak.*"
say "追加的项多半是空值,逐项填完再重启:sudo systemctl restart chan-live-exec"
exit 0
fi
# ── 1. 系统依赖 ────────────────────────────────────────────────────
say "装系统包"
if command -v apt-get >/dev/null; then
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
# chrony 不是可选项:staleness 闸靠两机时钟一致才有意义,
# 采集机时钟快 5 分钟就等于把闸放宽 5 分钟(见 ship_signals.py
apt-get install -y -qq python3-venv python3-pip git chrony openssh-client
elif command -v dnf >/dev/null; then
dnf install -y -q python3 python3-pip git chrony openssh-clients
else
die "只认 apt/dnf,其他发行版请手工装 python3-venv git chrony"
fi
systemctl enable --now chrony 2>/dev/null || systemctl enable --now chronyd
# ── 2. 专用用户与目录 ──────────────────────────────────────────────
if ! id -u "$USER_NAME" >/dev/null 2>&1; then
say "建系统用户 $USER_NAME(无登录 shell"
useradd --system --home-dir "$STATE/home" --create-home \
--shell /usr/sbin/nologin "$USER_NAME"
fi
install -d -o "$USER_NAME" -g "$USER_NAME" -m 750 "$STATE" "$STATE/state" "$STATE/home"
install -d -o "$USER_NAME" -g "$USER_NAME" -m 700 "$STATE/home/.ssh"
install -d -o root -g "$USER_NAME" -m 750 "$CONF"
# ── 3. 代码 ────────────────────────────────────────────────────────
if [[ -n "$REPO" ]]; then
if [[ -d "$APP/.git" ]]; then
say "更新已有 checkout"
git -C "$APP" fetch --quiet origin "$BRANCH"
git -C "$APP" checkout --quiet "$BRANCH"
git -C "$APP" reset --hard --quiet "origin/$BRANCH"
else
say "克隆 $REPO"
rm -rf "$APP"; git clone --quiet --branch "$BRANCH" "$REPO" "$APP"
fi
else
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
[[ -f "$SRC/live/live_exec.py" ]] || die "$SRC 不像仓库根(缺 live/live_exec.py"
if [[ "$SRC" != "$APP" ]]; then
say "$SRC 同步代码到 $APP"
install -d "$APP"
# 只同步生产要的那一个子树。研究侧的 research/ 不上生产机:
# 它带 pandas/pyarrow/hummingbot,而且探针 OOM 过一次(14.9GB)
cp -a "$SRC/live" "$APP/"
fi
fi
chown -R root:root "$APP/live"
find "$APP/live" -type f -exec chmod 644 {} + ; chmod 755 "$APP/live" "$APP/live/deploy"
chmod 755 "$APP"/live/deploy/*.sh
# ── 4. venv ───────────────────────────────────────────────────────
say "建 venv 并装依赖(应当只有 aiohttp"
[[ -d "$APP/.venv" ]] || python3 -m venv "$APP/.venv"
"$APP/.venv/bin/pip" install --quiet --upgrade pip
"$APP/.venv/bin/pip" install --quiet -r "$APP/live/requirements.txt"
# 断言生产进程没被拖进重量级依赖。这条会真挡住——曾经为读两个常量
# import 研究侧的 step43,把 numpy/pandas/pyarrow 全拉进实盘进程
say "验依赖面"
"$APP/.venv/bin/python" - <<'PY' || die "生产进程拖进了重量级依赖,看上面输出"
import sys
sys.path.insert(0, "/opt/chan/live")
import live_exec
live_exec.assert_decomposable()
heavy = [m for m in ("numpy", "pandas", "pyarrow", "hummingbot", "scipy")
if m in sys.modules]
if heavy:
print(f" ⛔ 启动路径加载了 {heavy}")
raise SystemExit(1)
print(f" ✓ 只有标准库 + aiohttp · 出场结构 "
f"{live_exec.SL_ATR}/{live_exec.SCALE_ATR}/{live_exec.RUNNER_ATR} ATR "
f"/{live_exec.MAXB} 根")
PY
# ── 5. 配置模板 ────────────────────────────────────────────────────
if [[ ! -f "$CONF/live.env" ]]; then
say "写配置模板 $CONF/live.env(密钥要你手工填)"
install -o root -g "$USER_NAME" -m 640 \
"$APP/live/deploy/live.env.example" "$CONF/live.env"
NEED_FILL=1
else
say "$CONF/live.env 已存在,不覆盖(里面是密钥)"
# 但要报出模板新增的项。不报的话,以后往 example 里加配置,已有部署会
# **永远拿不到且毫无提示**——静默漂移,等到出事才发现某个开关根本没生效
keys() { grep -oE '^[A-Z_][A-Z0-9_]*=' "$1" | tr -d '=' | sort -u; }
MISSING="$(comm -23 <(keys "$APP/live/deploy/live.env.example") \
<(keys "$CONF/live.env") | tr '\n' ' ')"
if [[ -n "${MISSING// }" ]]; then
say "⚠ 模板比你的配置多了这些项:$MISSING"
say " 逐项看说明:$APP/live/deploy/live.env.example"
say " 要把缺的那几行连注释一起追加过去,跑:"
say " sudo $APP/live/deploy/install.sh --sync-env"
NEED_FILL=1
fi
OBSOLETE="$(comm -13 <(keys "$APP/live/deploy/live.env.example") \
<(keys "$CONF/live.env") | tr '\n' ' ')"
[[ -n "${OBSOLETE// }" ]] && \
say " 另有模板里已没有的项(可能已废弃):$OBSOLETE"
fi
# ── 6. systemd ────────────────────────────────────────────────────
say "装 systemd 单元"
install -m 644 "$APP"/live/deploy/chan-live-*.service /etc/systemd/system/
systemctl daemon-reload
echo
echo "装好了。接下来按顺序做(**不要**跳过空跑那步):"
echo
if [[ -n "${NEED_FILL:-}" ]]; then
echo " 1. 填密钥与参数:sudo vi $CONF/live.env"
echo " BITGET_API_KEY / SECRET / PASSPHRASE 用只读+交易权限,"
echo " **不要开提币权限**。SHIP_FROM 填采集机的 ssh 目标。"
echo
fi
echo " 2. 装拉总线用的 ssh key"
echo " sudo -u $USER_NAME ssh-keygen -t ed25519 -N '' -f $STATE/home/.ssh/id_ed25519"
echo " # 把 $STATE/home/.ssh/id_ed25519.pub 加到采集机的 authorized_keys"
echo
echo " 还要写 known_hosts,否则报 Host key verification failed —— 服务跑"
echo " 起来会撞同一个墙,因为 BatchMode=yes 不允许交互确认:"
echo " sudo -u $USER_NAME ssh-keyscan -t ed25519 <采集机IP> \\"
echo " | sudo -u $USER_NAME tee -a $STATE/home/.ssh/known_hosts"
echo " sudo -u $USER_NAME ssh-keygen -lf $STATE/home/.ssh/known_hosts"
echo " # 把指纹跟采集机上 ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub"
echo " # 的输出比一遍。**不要**图省事用 StrictHostKeyChecking=no"
echo " # 那等于放弃中间人防护,而这条链路上跑的是下单信号"
echo
echo " 3. 空跑验全链(不下真单,跑够看到一次心跳再停):"
echo " sudo $APP/live/deploy/dryrun.sh"
echo
echo " 4. 真跑:"
echo " sudo systemctl enable --now chan-live-ship chan-live-exec"
echo " $APP/live/deploy/status.sh"
+71
View File
@@ -0,0 +1,71 @@
# 生产配置。装到 /etc/chan-live/live.env,权限 640 root:chan。
# **不要提交填好的版本**——这里有交易权限的密钥。
#
# 改完要重启:sudo systemctl restart chan-live-exec
# ── Bitget 密钥 ───────────────────────────────────────────────────
# 权限只勾「只读」+「交易」,**不要勾提币**。
# IP 白名单填这台机的公网出口 IPcurl -s https://api.ipify.org 看)。
# 这也是执行器必须跑在这台机上的唯一原因——密钥绑了这个 IP。
BITGET_API_KEY=
BITGET_API_SECRET=
BITGET_PASSPHRASE=
# ── 信号来源(采集机)─────────────────────────────────────────────
# ssh 目标。可以是 ~/.ssh/config 里的别名,或 user@ip
SHIP_FROM=sg-collector
# 采集机上总线文件的路径(在对端 shell 里展开,可用 ~)
SHIP_REMOTE_BUS=~/chan-live/state/signals_live.jsonl
# ── 状态与总线 ────────────────────────────────────────────────────
# 生产状态的根。**不要指到仓库里**git checkout/clean 会动仓库,而这里存的
# 是日亏损累计与在场仓位,被清掉等于 MAX_DAY_LOSS / MAX_OPEN 两道闸失忆。
# systemd 单元里 ReadWritePaths 也是这个路径,改了要一起改
LIVE_HOME=/var/lib/chan-live
SIGNAL_BUS=/var/lib/chan-live/state/signals_live.jsonl
# ── 仓位 ─────────────────────────────────────────────────────────
# 每笔名义额(USDT)。杠杆**不改**手续费与滑点(都按名义额收),所以抬名义额
# 有真实成本;抬它的唯一理由是压掉步长取整:实测最差币的偏差
# 100U → 6.7%(SOL)、500U → 1.8%、1000U → 0.6%
LIVE_NOTIONAL=500
# 杠杆只影响占用保证金,不影响名义敞口/手续费/滑点/盈亏绝对值。
# 名义 500 在 10x 下占 50 USDT 保证金;止损在 2 ATR ≈ 0.2%,而 10x 强平约需
# 逆向 10% = 100 个 ATR,差 50 倍。交易所侧记得设**逐仓**
LIVE_LEVERAGE=10
# ── 硬约束:封的是「代价不随仓位缩小」的那几类故障 ─────────────────
# 并发仓位数。信号 6.8 笔/天、持仓 48 分钟 → 期望并发 0.23 笔,3 已很宽。
# 超了说明有 bug,不是行情好
LIVE_MAX_OPEN=3
# 日开仓上限。专门封「循环里的 bug 反复开仓」——单笔小但笔数无界
LIVE_MAX_DAY=15
# 日亏损上限(USDT)。一笔止损约 1 USDT15 笔全亏 15 USDT
LIVE_MAX_DAY_LOSS=20
# 信号超过这么久就不做。参考成交价是次根开盘价,过期后跑的不是回测那个价。
# ⚠️ 这道闸依赖两机时钟一致,chrony 必须在跑(install.sh 会装)
LIVE_STALE_S=20
# 交易的币池。要与采集机一致,否则会收到不做的币的信号(会被忽略但徒增噪声)
SYMS=BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC
# 盯交易所侧出场的轮询间隔(秒)。**别关掉这个循环**:止损与止盈在交易所侧
# 成交,本进程收不到通知,缺了它 pnl_day 恒为 0MAX_DAY_LOSS 就是死的
LIVE_WATCH_S=10
# ── Telegram 通知 ─────────────────────────────────────────────────
# 拿 tokenTelegram 里找 @BotFather → /newbot
# 拿 chat id:给 bot 随便发一句,再开
# https://api.telegram.org/bot<TOKEN>/getUpdates 看 result[0].message.chat.id
#
# 留空则完全不推(不报错)。推的内容:开仓、平仓(带已实现盈亏)、被硬约束
# 挡住、报错、对账平仓、跨日结算、启动与停机、整点在线。
# **不推**信号过期跳过(常态,搬运重连会重放旧信号)和 5 分钟日志心跳。
# 量级约 55 条/天上限(含 24 条在线)。
TG_TOKEN=
TG_CHAT=
# 前缀,用来和采集机推的手工信号区分开——两边可以共用同一个 bot 和对话
TG_TAG=实盘
# 整点在线的间隔(分钟)。0 关掉。60 = 每天 24 条,和成交推送量级相当。
# 这条必须带上游新鲜度:执行器活着不代表链路活着
TG_HB_MIN=60
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# 生产机一屏体检。不改任何状态,随时可跑。
set -uo pipefail
APP=/opt/chan
STATE=/var/lib/chan-live/state
CONF=/etc/chan-live/live.env
hr() { printf '─── %s\n' "$1"; }
hr "服务"
for u in chan-live-ship chan-live-exec; do
act="$(systemctl is-active "$u" 2>/dev/null)"
since="$(systemctl show -p ActiveEnterTimestamp --value "$u" 2>/dev/null)"
nrs="$(systemctl show -p NRestarts --value "$u" 2>/dev/null)"
printf ' %-16s %-8s 自 %s · 重启 %s 次\n' \
"$u" "$act" "${since:-?}" "${nrs:-0}"
done
hr "时钟(staleness 闸依赖它)"
if command -v chronyc >/dev/null; then
chronyc tracking 2>/dev/null | grep -E "Reference ID|System time" | sed 's/^/ /'
else
echo " ⚠ 没装 chrony"
fi
hr "信号总线"
bus="${SIGNAL_BUS:-$STATE/signals_live.jsonl}"
[[ -f "$CONF" ]] && bus="$(grep -E '^SIGNAL_BUS=' "$CONF" | tail -1 | cut -d= -f2-)"
bus="${bus:-$STATE/signals_live.jsonl}"
if [[ -s "$bus" ]]; then
n="$(wc -l < "$bus")"
last_ts="$(tail -1 "$bus" | grep -o '"kline_ts":[0-9]*' | cut -d: -f2)"
if [[ -n "$last_ts" ]]; then
age=$(( $(date +%s) - last_ts / 1000 ))
printf ' %s 条 · 最近一条 %d 分钟前\n' "$n" "$((age / 60))"
else
echo " $n 条(最后一行没有 kline_ts"
fi
# 信号 6.8 个/天,所以「几小时没有」是正常的。真要看的是搬运连着没有
echo " 注:6.8 个/天,长时间没有新信号是正常的;要判健康看下面的搬运心跳"
else
echo " 空或不存在:$bus"
fi
hr "闸的状态"
# 在场仓位**不落盘**:重启时由 reconcile 查交易所并平掉(见 live_exec.py
# 的 reconcile 注释)。所以这里只报当日计数,仓位要看交易所或下面的成交流
if [[ -f "$STATE/live_state.json" ]]; then
python3 - "$STATE/live_state.json" <<'PY'
import json, sys, time
d = json.load(open(sys.argv[1]))
today = time.strftime("%Y-%m-%d")
day = d.get("day", "?")
stale = "" if day == today else f" ⚠ 是 {day} 的,跨日后首次开仓时才归零"
print(f" 当日 {day}{stale}")
pnl, n = d.get("pnl_day", 0.0), d.get("n_day", 0)
# pnl 恒为 0 而又确实开过仓,说明 watch() 没在记账 → MAX_DAY_LOSS 是死的
warn = " ⚠ 开过仓但盈亏仍为 0,查 watch() 是否在跑" if n and pnl == 0 else ""
print(f" 已开 {n} 笔 · 盈亏 {pnl:+.2f} USDT{warn}")
print(f" 已处理信号键 {len(d.get('done', []))} 个(幂等去重用,留最近 5000")
PY
else
echo " 还没有状态文件(没开过仓)"
fi
hr "最近成交"
if [[ -s "$STATE/live_trades.jsonl" ]]; then
tail -5 "$STATE/live_trades.jsonl" | sed 's/^/ /'
# 「下过单但一次都没建上」是明确的故障,而它的外在表现和"没信号"一样,
# 不主动判读就会白跑几小时。首日就是这么丢掉 4 个信号的
nf="$(grep -c '"ev": "entry_fail"' "$STATE/live_trades.jsonl" || true)"
nb="$(grep -c '"ev": "opened", "key": [^]]*\[{' "$STATE/live_trades.jsonl" || true)"
ne="$(grep -c '"ev": "entry"' "$STATE/live_trades.jsonl" || true)"
if [[ "${nf:-0}" -gt 0 ]]; then
echo
echo " ⛔ 有 $nf 次入场被拒(共尝试 $ne 个信号)"
echo " 最后一条错误:"
grep '"ev": "entry_fail"' "$STATE/live_trades.jsonl" | tail -1 \
| sed 's/^/ /'
echo " 40774 = 持仓模式不匹配 · 40762/40786 = 保证金不足"
fi
else
echo " 还没有成交记录"
fi
hr "搬运存活文件"
if [[ -f "$STATE/ship_alive.json" ]]; then
python3 - "$STATE/ship_alive.json" <<'PY'
import json, sys, time
d = json.load(open(sys.argv[1]))
age = time.time() - d.get("ts", 0)
up = d.get("up_s", 0)
conn = d.get("connected")
if age > 720:
state = f"⛔ 已停更 {age/60:.0f} 分钟,进程可能死了"
elif conn is False:
state = "⛔ ssh 已断开,正在重连"
elif conn is True:
state = f"ssh 在线 {up/60:.0f} 分钟"
else:
state = "文件是旧格式(没有 connected),重启搬运后才会有"
print(f" {state} · 重连 {d.get('n_reconnect', 0)} 次 · 新增 {d.get('n_new', 0)} 条")
PY
else
echo " 还没有 ship_alive.json(搬运没起来,或还是没落盘的旧版本)"
fi
hr "搬运心跳(最近 3 条)"
journalctl -u chan-live-ship -n 200 --no-pager 2>/dev/null \
| grep -F "[心跳]" | tail -3 | sed 's/^/ /' \
|| echo " 还没有心跳(每 5 分钟一条)"
hr "最近报错"
journalctl -u chan-live-exec -u chan-live-ship -n 400 --no-pager -p warning 2>/dev/null \
| tail -8 | sed 's/^/ /' || echo " 无"
+118
View File
@@ -0,0 +1,118 @@
"""钱在哪个账户里。
用来解一个具体的矛盾:你确认往 U 本位合约充了钱,但
`/api/v2/mix/account/account` 报的 accountEquity 只有一小部分。
`accountEquity` 是**总权益**而不是可用余额,locked 也是 0,所以不是被挂单
或持仓占着。剩下的可能都是「这把 key 看到的不是你充钱的那个账户」:
· key 属于子账户,钱在主账户(或反过来)
· 钱在现货账户,没划转到合约
· 钱在 USDC 本位 / 币本位合约,不是 USDT 本位
· 账户已迁到统一账户(UTA),经典 mix 接口读到的不是同一个池子
`/api/v2/account/all-account-balance` 会按账户类型列出全部余额,一次看清。
sudo -u chan bash -c 'set -a; . /etc/chan-live/live.env; set +a; \
/opt/chan/.venv/bin/python /opt/chan/live/deploy/whereismoney.py'
只读,不下单、不划转。
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from bitget_rest import MARGIN_COIN, PRODUCT, Bitget # noqa: E402
def _perm_hint(e: Exception) -> str:
"""把 40014 说成"正常"而不是"故障"
这把 key 刻意只开合约权限,所以现货类端点必然报 40014。**不要**为了让
这个探针看全就去加现货权限——那是白扩爆炸半径,而同样的信息在 App 里
看一眼就有。
"""
s = str(e)
if "40014" in s:
return ("跳过:这把 key 没开现货权限(刻意的,最小权限)。"
"这一栏改用 Bitget App 看,别为了探针去加权限")
return f"查不了:{type(e).__name__}: {e}"
async def main() -> None:
api = Bitget()
if not (api.key and api.secret and api.passphrase):
raise SystemExit("⛔ 没读到密钥。要 source /etc/chan-live/live.env")
try:
print("── 跨账户类型总览 ──")
try:
for b in await api._req("GET", "/api/v2/account/all-account-balance") or []:
amt = float(b.get("usdtBalance") or 0)
flag = " ← 钱在这里" if amt > 1 else ""
print(f" {b.get('accountType'):<16} {amt:>12.2f} USDT{flag}")
except Exception as e: # noqa: BLE001
print(f" {_perm_hint(e)}")
print(f"\n── {PRODUCT} 下的各保证金币种 ──")
try:
rows = await api._req("GET", "/api/v2/mix/account/accounts",
{"productType": PRODUCT}) or []
for a in rows:
eq = float(a.get("accountEquity") or 0)
if eq or a.get("marginCoin") == MARGIN_COIN:
print(f" {a.get('marginCoin'):<8} 权益 {eq:>12.4f} · "
f"可用 {float(a.get('available') or 0):>12.4f}")
except Exception as e: # noqa: BLE001
print(f" {_perm_hint(e)}")
print("\n── 其他合约类型(钱可能充错了本位)──")
for pt in ("coin-futures", "usdc-futures"):
try:
rows = await api._req("GET", "/api/v2/mix/account/accounts",
{"productType": pt}) or []
hit = [(a.get("marginCoin"), float(a.get("accountEquity") or 0))
for a in rows if float(a.get("accountEquity") or 0) > 0]
print(f" {pt:<14} {hit if hit else ''}")
except Exception as e: # noqa: BLE001
print(f" {pt:<14} 查不了:{type(e).__name__}")
print("\n── 现货 ──")
try:
rows = await api._req("GET", "/api/v2/spot/account/assets") or []
hit = [(a.get("coin"), float(a.get("available") or 0)) for a in rows
if float(a.get("available") or 0) > 0]
print(f" {hit if hit else ''}"
f"{' ← 要划转到 U 本位合约' if hit else ''}")
except Exception as e: # noqa: BLE001
print(f" {_perm_hint(e)}")
print("\n── 这把 key 属于哪个账户 ──")
for path in ("/api/v2/spot/account/info", "/api/v2/user/account-info"):
try:
d = await api._req("GET", path) or {}
except Exception as e: # noqa: BLE001
print(f" {path} 查不了:{type(e).__name__}: {e}")
continue
uid, par = d.get("userId"), d.get("parentId")
print(f" userId {uid}")
if par:
print(f" parentId {par} → **这是子账户**。主账户的钱这把 key"
f" 看不到也动不了,这是好事(爆炸半径被账户边界封住)。"
f"\n 但充值要充到 userId {uid} 的 U 本位合约里。"
f"\n 注意主→子划转默认落在子账户的**现货**钱包,"
f"还要在子账户内部再划一次到 U 本位合约")
else:
print(" 没有 parentId → 这是主账户")
print(f" 权限 {d.get('authorities')}(没有现货权限是刻意的)")
print(f" IP 白名单 {d.get('ips')}")
break
finally:
await api.close()
if __name__ == "__main__":
asyncio.run(main())
+27
View File
@@ -0,0 +1,27 @@
"""出场结构的唯一来源。**只用标准库**,这是硬约束。
为什么单独一个模块、且不许引第三方库:执行器要跑在只装了 `aiohttp` 的生产机
上。这几个数原先从 `research/step43_fill_aware_budget.py` 读,那个模块顶层
`import pandas`,于是生产机为了两个 float 得装 pandas + pyarrow(实测
`assert_decomposable()` 一调就把 numpy/pandas/pyarrow 全拖进来)。
方向也要注意:**生产拥有这个契约,研究侧反过来读它。** 反过来写成生产 import
研究侧,就等于把回测的依赖树绑到实盘进程上。
⚠️ 研究侧还散着 6 处同样的字面量(step44/47/48/49/52 与 exit_model 的默认
参数),本次没有统一。改这里的值**不会**自动改到那些脚本,对表要手工。
统一它们要重跑那批脚本确认结果不变,属于独立的一次改动。
"""
from __future__ import annotations
# 以 ATR 为单位的出场结构。来源:research/step43_fill_aware_budget.py 的
# 参数扫描结论(1m 主线)。含义见 live_exec.py 顶部注释
SL = 2.0 # 止损:入场价的 2 ATR
SCALE_AT = 3.0 # 减半点:3 ATR 处平掉一半
RUNNER = 8.0 # 剩余半仓的目标:8 ATR
RUNNER_STOP = 2.0 # 剩余半仓的止损,**不移动**,仍在入场价的 2 ATR
MAXB = 48 # 超时:48 根(1m 上即 48 分钟)
# 两个半仓共用同一个不动止损,这是「一次入场拆两腿」能等价于回测的前提。
# RUNNER_STOP != SL 时该等价性失效,`live_exec.assert_decomposable()` 会硬挡
DECOMPOSABLE = RUNNER_STOP == SL
+880
View File
@@ -0,0 +1,880 @@
"""自动化小额实盘执行器。读信号总线,直接调 Bitget v2 REST 下单。
## 为什么不用 Hummingbot 的 PositionExecutor
它的连接器只暴露 LIMIT / LIMIT_MAKER / MARKET,没有触发单,于是
`control_stop_loss()` 只能在本地盯价、触发时才发市价单——**进程一死仓位就是
裸的**。而交易所本身支持 `place-order` 带 `presetStopLossPrice`,下单时就把
止损挂到服务端。绕过连接器不是图省事,是为了消掉一整类故障。
另外 `TripleBarrierConfig` 只有单级止盈,装不下 3 ATR 减半 + 8 ATR 目标;
自己写反而更短。
## 出场结构为什么能拆成两个半仓
回测结构是 2 ATR 止损 / 3 ATR 减半 / 8 ATR 目标 / 48 根超时,且**剩余半仓的
止损保持在入场价的 2 ATR、不移动**。已核实 `research/lib/exit_model.py:151`——
`runner_stops` 的 `ret` 是 `(entry - low[j]) / a`,从入场价算,且
`RUNNER_STOP == SL == 2.0`。两半共用同一个不动的止损,所以:
半仓 A 市价入场 + 服务端止损 2 ATR · maker 止盈 3 ATR
半仓 B 市价入场 + 服务端止损 2 ATR · maker 止盈 8 ATR
止损先到则两半都在 -2 ATR 出场;3 ATR 先到则 A 出场、B 继续且止损仍在 2 ATR。
与回测逐情形一致。若哪天把 RUNNER_STOP 改成不等于 SL(比如移到成本),这个
分解就**不再成立**`assert_decomposable()` 会在启动时挡住。
## 三条出场腿各自挂在哪
止损 交易所侧(presetStopLossPrice,随入场单一起到)→ 进程死了仍在
止盈 交易所侧(post_only reduce-only 限价) → 进程死了仍在
超时 **本进程**,48 分钟到点市价平
所以进程死掉只会让持仓超过 48 根,不会变成裸仓——退化是良性的。
## 硬约束才是这个文件的重点
一笔止损只亏约 1 USDT,所以"亏损可控"对单笔成立。但三类故障的代价**不随仓位
缩小**,必须显式封住:
失控下单 循环里的 bug 反复开仓,单笔小但笔数无界 → MAX_OPEN / MAX_DAY
亏损累积 策略真的不行,但没人盯着 → MAX_DAY_LOSS
裸仓 进程在"已入场、止损未挂"之间死掉 → 服务端止损 + 重启对账
## 为什么单独一个 live/ 子树、不放在 research/ 下
生产与研究共处一个目录/进程/机器有四条具体代价,其中第一条已经咬过一次:
1. `live_state.json` 原先落在 `research/out/`,而那里 `shadow_hb.py` 会在
CSV 表头变化时自动 rename 归档、研究脚本会写、人也会手工清数据。那个文件
装的是 MAX_DAY_LOSS 累计与在场仓位,**闸的状态被清掉不报错,只是静默
失效**。所以生产状态改到独立目录(LIVE_HOME)。
2. 采集器十币清空 300~560ms,直接叠在信号到达执行器的延迟上。
3. 研究侧的探针 OOM 过一次(14.9GB、负载 12),当时若有仓位在场,执行器会
被一起杀掉,只剩交易所侧止损兜着。
4. 依赖面:本文件只需标准库 + aiohttp。原先为读两个常量 import 研究侧的
step43,把 numpy/pandas/pyarrow 全拖进生产进程。
因此本目录**不 import research/ 下的任何东西**`exit_params.py` 是生产自己
持有的契约,研究侧反过来读它)。
python live/live_exec.py --dry-run # 只打印不下单
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import signal
import sys
import time
from decimal import Decimal
from pathlib import Path
HERE = Path(__file__).resolve()
# 只插自己所在目录。**不要**把 research/ 加进来——见文件头第 4 条
sys.path.insert(0, str(HERE.parent))
import signal_bus # noqa: E402
from bitget_rest import Bitget # noqa: E402
import tg # noqa: E402
from exit_params import MAXB, RUNNER, RUNNER_STOP, SCALE_AT, SL # noqa: E402
# 生产状态的根目录。默认放 ~/chan-live,**不落在仓库里**:仓库会被 git
# checkout/clean 动,而这里存的是日亏损累计与在场仓位,丢了等于闸失忆
LIVE_HOME = Path(os.environ.get("LIVE_HOME", Path.home() / "chan-live"))
SYMS = os.environ.get(
"SYMS", "BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC").split(",")
# 只认这些交易对。交易所的 all-position 返回**账户全部**仓位,不过滤的话
# 账户上任何第三方仓位(手工单、另一个策略、试单忘了平)都会被 reconcile
# 在下次重启时市价平掉;watch() 还会因为该 symbol 一直在场而永不结算对应的
# keyMAX_OPEN 名额泄漏、pnl_day 不再更新。把「账户只归执行器」这个前提
# 从口头约定变成代码里的过滤
PAIRS = frozenset(f"{s}USDT" for s in SYMS)
NOTIONAL = float(os.environ.get("LIVE_NOTIONAL", "500"))
LEVERAGE = int(os.environ.get("LIVE_LEVERAGE", "10"))
# ── 硬约束 ────────────────────────────────────────────────────────────
# 并发仓位数。1 笔约占 50 USDT 保证金,3 笔 150 USDT。信号速率 5.3 笔/天、
# 持仓 48 分钟,期望并发只有 0.18 笔,所以 3 已经很宽——超了说明有 bug
MAX_OPEN = int(os.environ.get("LIVE_MAX_OPEN", "3"))
# 日开仓上限。实测 5.3 笔/天,给 3 倍余量。这一条专门封"失控下单"
MAX_DAY = int(os.environ.get("LIVE_MAX_DAY", "15"))
# 日亏损上限(USDT)。一笔止损约 1 USDT15 笔全亏 15 USDT
MAX_DAY_LOSS = float(os.environ.get("LIVE_MAX_DAY_LOSS", "20"))
# 信号超过这么久就不做了。参考成交价是次根开盘价,过期后跑的不是回测那个价
STALE_S = float(os.environ.get("LIVE_STALE_S", "20"))
# 盯交易所侧出场的轮询间隔。10s 足够:出场后要做的只是记账与放开 MAX_OPEN
# 名额,不涉及下单时效。太密会白耗 API 配额
WATCH_S = float(os.environ.get("LIVE_WATCH_S", "10"))
# 整点在线推送的间隔(分钟),0 关掉。60 分钟 = 24 条/天,和成交推送量级相当
# 不会淹掉真事。调到 5 以下没意义:日志心跳就是 5 分钟一次
TG_HB_MIN = float(os.environ.get("TG_HB_MIN", "60"))
STATE = Path(os.environ.get("LIVE_STATE", LIVE_HOME / "state" / "live_state.json"))
TRADES = Path(os.environ.get("LIVE_TRADES", LIVE_HOME / "state" / "live_trades.jsonl"))
# 出场结构从 exit_params 读,本文件不再抄一份字面量。抄一份的问题不是难看,
# 是改了回测参数后这边不会跟上,而且不报错
SL_ATR, SCALE_ATR, RUNNER_ATR = SL, SCALE_AT, RUNNER
def assert_decomposable() -> None:
"""两个半仓的分解依赖 RUNNER_STOP == SL,不成立就必须停机。
若有人把剩余半仓的止损改成移到成本(RUNNER_STOP=0)或任何 != SL 的值,
这个分解就变成"两半共用同一止损"的错误近似,实盘跑的是另一个收益结构,
而且不会报错。所以在启动时硬挡。
"""
if float(RUNNER_STOP) != float(SL):
raise SystemExit(
f"⛔ RUNNER_STOP({RUNNER_STOP}) != SL({SL}),两个半仓的分解不再\n"
f" 成立。live_exec 的出场结构会与回测不一致且不报错。\n"
f" 要改成单执行器 + 手工两级止盈,或把这两个值改回一致。")
class Guard:
"""硬约束与当日计数。状态落盘,重启后不清零。
不落盘的话,进程反复重启就等于反复重置日上限——"失控下单"这一类恰好常常
伴随反复重启,那时上限必须还记得。
"""
def __init__(self, path: Path = STATE):
self.path = path
self.day = time.strftime("%Y-%m-%d")
self.n_day = 0
self.pnl_day = 0.0
self.done: set = set()
self.pending_roll: tuple | None = None
self._load()
def _load(self) -> None:
try:
d = json.loads(self.path.read_text())
except Exception:
return
# 跨日则计数归零,但已处理过的信号键要保留,否则会重开旧仓
if d.get("day") == self.day:
self.n_day = int(d.get("n_day", 0))
self.pnl_day = float(d.get("pnl_day", 0.0))
self.done = set(d.get("done", []))
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
# 原子替换:直接覆写时若在写一半崩溃,状态文件会变成半个 JSON,
# 重启后读不出来 → 日计数归零 → 上限失效
tmp.write_text(json.dumps({
"day": self.day, "n_day": self.n_day, "pnl_day": self.pnl_day,
# 只留最近的,否则文件无界增长
"done": sorted(self.done)[-5000:]}))
tmp.replace(self.path)
def roll(self) -> None:
today = time.strftime("%Y-%m-%d")
if today != self.day:
print(f" [guard] 跨日 {self.day}{today}"
f"当日 {self.n_day} 笔 / PnL {self.pnl_day:+.2f} USDT",
flush=True)
# roll() 是同步的,推送要 await,所以只留个待发件,由心跳取走
self.pending_roll = (self.day, self.n_day, self.pnl_day)
self.day, self.n_day, self.pnl_day = today, 0, 0.0
self.save()
def blocks(self, key: str, n_open: int) -> str | None:
"""返回拒绝原因,None 表示放行。"""
self.roll()
if key in self.done:
return "已处理过(幂等)"
if n_open >= MAX_OPEN:
return f"并发仓位已达上限 {MAX_OPEN}"
if self.n_day >= MAX_DAY:
return f"当日开仓已达上限 {MAX_DAY}"
if self.pnl_day <= -MAX_DAY_LOSS:
return (f"当日亏损 {self.pnl_day:.2f} 已达上限 "
f"-{MAX_DAY_LOSS},停止开新仓")
return None
def took(self, key: str) -> None:
self.done.add(key)
self.n_day += 1
self.save()
def realized(self, pnl: float) -> None:
self.pnl_day += pnl
self.save()
def legs() -> list[dict]:
"""两个半仓的止盈位,用 ATR 倍数表达。
止损两半相同(SL_ATR),所以不写在这里——它在 open_position 里算一次。
"""
return [{"tag": "scale", "atr": SCALE_ATR},
{"tag": "runner", "atr": RUNNER_ATR}]
def oid_of(key: str, tag: str) -> str:
"""把信号键变成交易所能接受的 clientOid。
信号键形如 `SOL:1787904388411:+1`,里面的 `:` 和 `+` 未必被交易所接受,
带过去会直接拒单——而拒单发生在入场腿上,等于这笔信号静默漏掉。只留
字母数字和下划线。
clientOid 是**交易所级幂等**:重发同一个 oid 会被拒。这比本地去重可靠,
因为「已发出但没收到回复」这种情况本地判不了,重试就会开两次仓。
调用方拼后缀时也要守这个字符集(用 `_tp` 而不是 `-tp`)。曾经拼过 `-`,
和这里的理由自相矛盾;真被拒的话止盈单挂不上,而那条路径只告警不停机,
收益结构会静默退化成「只有止损 + 超时」,空跑还验不到(dry 直接返回
假成功)。
"""
# 方向必须显式编码:直接把非字母数字换成下划线,会让 `+1` 和 `-1` 都变成
# `_1`,同一根上的多空信号得到相同 oid,第二笔被交易所当重复拒掉
k = key.replace(":+1", ":L").replace(":-1", ":S")
safe = "".join(c if c.isalnum() else "_" for c in f"{k}_{tag}")
return safe[:60]
def log_trade(rec: dict, path: Path = TRADES) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a") as f:
f.write(json.dumps(rec) + "\n")
f.flush()
os.fsync(f.fileno())
class Exec:
def __init__(self, dry: bool, bus: Path):
self.dry = dry
self.bus = bus
self.guard = Guard()
self.api: Bitget | None = None
self.rules: dict = {}
self.execs: dict = {} # key → 该笔的腿与超时时刻
self.offset = 0 # 已读到总线的哪一行
self.n_seen = self.n_took = self.n_skip = 0
# 建仓成功/失败分开计。只看 n_took 分不出"做了但下单被拒"——首日
# 那 5 小时里 n_took=4 而实际一笔都没建上
self.n_built = self.n_order_fail = 0
self.t0 = time.time()
# 置 0 让第一次 5 分钟心跳就推,不必等满一个周期:重启后最该尽早确认
# 的是「上游也通」,而这个只有在线那条带得出来
self.tg_hb_at = 0.0
# ── 启动 ──────────────────────────────────────────────────────
async def start(self) -> None:
assert_decomposable()
# 只从"现在"往后做。历史信号的参考成交价早已过期,补做等于随机入场
self.offset = sum(1 for _ in signal_bus.read_all(self.bus))
print(f" 总线已有 {self.offset} 条历史信号,全部跳过(参考价已过期)",
flush=True)
self.api = Bitget(dry=self.dry)
self.rules = await self.api.contracts()
print(f" 合约规则 {len(self.rules)}", flush=True)
# 启动推送兼作"通道通不通"的自检:配错了这里就收不到,而不是等到
# 几小时后第一个真信号来时才发现
await tg.started(NOTIONAL, LEVERAGE, len(SYMS), self.dry)
print(f" Telegram {'已启用' if tg.ENABLED else '未配置(不推送)'}",
flush=True)
if self.dry:
print(" ⚠ 空跑模式:不下真单", flush=True)
# 但仍要发一次**带签名**的请求。上面的 contracts() 是公开端点,
# 不验签,光靠它空跑会"通过"却根本没测到密钥与 IP 白名单——
# 那种假保证比不测更糟:等到第一个真信号来时才暴露,而信号那时
# 正在过期,没有从容排查的余地
if self.api.key and self.api.secret and self.api.passphrase:
try:
acc = await self.api.account() or {}
print(" ✓ 密钥与 IP 白名单通", flush=True)
# 打全几个余额字段,不只看 available:我们用逐仓,真正
# 决定能不能开的是 isolatedMaxAvailable。只看一个字段,
# 取错了就会把"有钱"误报成"没钱",或者反过来
fields = ("accountEquity", "usdtEquity", "available",
"isolatedMaxAvailable", "crossedMaxAvailable",
"maxTransferOut", "locked", "unrealizedPL")
shown = {k: acc.get(k) for k in fields if k in acc}
print(f" 余额 {shown}", flush=True)
# 逐仓下取 isolatedMaxAvailable,缺了才退回 available
av = float(acc.get("isolatedMaxAvailable")
or acc.get("available") or 0)
need = NOTIONAL / LEVERAGE * MAX_OPEN
print(f" 可开保证金 {av:.2f} USDT · {MAX_OPEN} 笔并发"
f"需约 {need:.0f}(名义 {NOTIONAL:.0f} / "
f"{LEVERAGE}x", flush=True)
if av < need:
per = NOTIONAL / LEVERAGE
fit = int(av // per) if per > 0 else 0
print(f" ⚠ 保证金只够 {fit} 笔,而 MAX_OPEN="
f"{MAX_OPEN}", flush=True)
# 这里不只是"少做几笔"。两条腿是分别下单的,第一条
# 成了、第二条因保证金不足失败,就留下一个半仓——
# 收益结构从「50% 在 3 ATR + 50% 在 8 ATR」变成只剩
# 一条腿,而且是静默的。让闸按真实余额拦,比让交易所
# 拒单干净
print(f" 要么充钱到 {need:.0f}+ USDT,要么把 "
f"LIVE_MAX_OPEN 降到 {max(fit, 1)}。不改的话"
f"超出的信号会下单失败,且可能只成一条腿、"
f"静默变成半仓(收益结构就不是设计的那个了)",
flush=True)
if fit == 0:
print(f" 现在连一笔都开不了(每笔需 "
f"{per:.0f} USDT", flush=True)
except Exception as e: # noqa: BLE001
# 退出前要关会话。SystemExit 会绕过 run() 里的收尾,
# 漏了就在日志尾部留一串 "Unclosed client session"
# 把真正的报错顶出视野
await self.api.close()
raise SystemExit(
f"⛔ 带签名的请求失败:{type(e).__name__}: {e}\n"
f" 40018 = 出口 IP 不在该 key 的白名单里;\n"
f" 40037 = key 不存在(填错或已删);\n"
f" 40001/40009 = secret/passphrase 不对;\n"
f" 40099 之类 = 权限没开够(要读+交易)。")
else:
print(" ⚠ 没填密钥,本次**未**验证密钥与 IP 白名单",
flush=True)
return
if not (self.api.key and self.api.secret and self.api.passphrase):
await self.api.close()
raise SystemExit("⛔ 缺 BITGET_API_KEY / BITGET_API_SECRET / "
"BITGET_PASSPHRASE(末项无 API_)。先跑 --dry-run。")
await self.setup_symbols()
await self.reconcile()
# 放在 reconcile 之后:有持仓或挂单时交易所不允许切换持仓模式,而
# reconcile 刚把仓位平干净
await self.setup_position_mode()
async def setup_position_mode(self) -> None:
"""把持仓模式钉成单向,并读回核对。
为什么必须显式设而不是假设:持仓模式决定下单体的语法,两者不匹配是
**整体拒单**,不是部分降级。实盘上就因为带着 `tradeSide`(双向语法)
打到单向账户,连续 8 次下单全被 40774 拒掉——4 个信号 × 2 条腿,而
投递链路那时完全正常(延后 0.0~0.2s、ssh 零重连)。
单向是我们要的:永不同时持有两个方向,且出场腿依赖 `reduceOnly`
而它只在单向模式下有效。
"""
try:
d = await self.api.set_position_mode("one_way_mode") or {}
except Exception as e: # noqa: BLE001
print(f" ⛔ 设持仓模式失败 {type(e).__name__}: {e}", flush=True)
print(" 若账户实际是双向持仓,下单会被 40774 全部拒掉。"
"先在 App 里平掉所有仓位与挂单,再切成单向", flush=True)
await tg.error("设持仓模式失败,下单可能被 40774 全拒", str(e))
return
got = str(d.get("posMode") or "")
if got and got != "one_way_mode":
print(f" ⛔ 持仓模式仍是 {got},下单体是单向语法,会被 40774 拒",
flush=True)
await tg.error(f"持仓模式是 {got},不是单向", "下单会被 40774 拒掉")
else:
print(f" 已设持仓模式 单向{'(已读回核对)' if got else ''}",
flush=True)
async def setup_symbols(self) -> None:
"""逐仓 + 杠杆。每次启动都设一遍,不假设交易所侧的状态。
杠杆若被人在 App 里改过,仓位大小就不是我们算的那个。设成幂等操作比
读回来核对简单,且失败会直接暴露。
"""
bad: list[str] = []
for s in SYMS:
sym = f"{s}USDT"
for fn, arg in ((self.api.set_margin_mode, "isolated"),
(self.api.set_leverage, LEVERAGE)):
try:
await fn(sym, arg)
except Exception as e:
print(f"{sym} 设置失败 {type(e).__name__}: {e}",
flush=True)
bad.append(f"{sym}/{fn.__name__}")
if not bad:
print(f" 已设 {len(SYMS)} 个币为逐仓 {LEVERAGE}x", flush=True)
return
# 不能无条件报"已设好"。杠杆设失败是有经济后果的:仓位大小由名义额
# 算、与杠杆无关,但保证金要求会变。若交易所侧实际是 1x,每笔要 100
# USDT 保证金,第 2、3 笔必然失败,且可能只成一条腿变成半仓
print(f"{len(bad)} 项设置失败,交易所侧的逐仓/杠杆**不是** "
f"{LEVERAGE}x{bad[:6]}{'' if len(bad) > 6 else ''}",
flush=True)
print(" 后果不是不能交易,而是保证金要求与我们算的不一致——"
"可能只成一条腿、静默变成半仓。先在 App 里核一遍再跑",
flush=True)
await tg.error(f"{len(bad)} 项逐仓/杠杆设置失败",
f"交易所侧不是 {LEVERAGE}x{bad[:10]}")
async def reconcile(self) -> None:
"""启动时把交易所的实际持仓对上。
崩溃重启后交易所可能还有仓位。它们的服务端止损仍在(presetStopLossPrice
挂在交易所侧,不随进程消失),但**超时腿丢了**,会一直持有到止损或止盈。
选择平掉而非接管:接管要重建入场价、ATR、剩余半仓状态和已过根数,任一项
猜错就让出场结构变成另一个东西且不报错;平掉的代价只是一笔小额亏损,
且行为确定。
"""
try:
pos = await self.my_positions()
except Exception as e:
print(f" ⚠ 对账读持仓失败 {type(e).__name__}: {e}", flush=True)
return
if not pos:
print(" 对账:交易所无持仓,干净启动", flush=True)
return
print(f" ⚠ 对账:发现 {len(pos)} 个遗留持仓,撤挂单后市价平掉",
flush=True)
await tg.error(f"对账:发现 {len(pos)} 个遗留持仓,撤挂单后市价平掉",
"".join(f"{p['symbol']} {p['holdSide']} {p['total']}"
for p in pos))
for p in pos:
sym, hs, sz = p["symbol"], p["holdSide"], p["total"]
print(f" {sym} {hs} {sz} @ {p.get('openPriceAvg')}",
flush=True)
try:
await self.api.cancel_all(sym)
await self.api.close_market(
sym, hs, sz, f"recon:{int(time.time() * 1000)}")
log_trade({"ev": "reconcile_flatten", "symbol": sym,
"hold_side": hs, "size": sz})
except Exception as e:
print(f" ⛔ 平仓失败 {type(e).__name__}: {e}"
f"需人工介入", flush=True)
# ── 主循环 ────────────────────────────────────────────────────
async def poll(self) -> None:
while True:
try:
await self.step()
except Exception as e:
import traceback
print(f" ⛔ 主循环异常 {type(e).__name__}: {e}", flush=True)
traceback.print_exc()
await asyncio.sleep(0.2)
async def step(self) -> None:
recs = list(signal_bus.read_all(self.bus))
if len(recs) <= self.offset:
return
new, self.offset = recs[self.offset:], len(recs)
for r in new:
self.n_seen += 1
await self.on_signal(r)
# 一条记录要能下单,这些字段一个都不能缺
NEEDED = ("key", "sym", "emit_ms", "direction", "entry_px", "atr_pct")
async def my_positions(self) -> list:
"""只看 SYMS 里那些币的仓位。
**不要**直接用 api.positions():它返回账户全部仓位,理由见 PAIRS。
残留的局限要知道:同一个币上的第三方仓位(比如你手工开了 ADA)仍然
分不出来,那需要按 clientOid 逐单认领,成本不划算。所以账户仍应专用。
"""
return [p for p in await self.api.positions() if p["symbol"] in PAIRS]
async def on_signal(self, r: dict) -> None:
# 先校验再取值。缺了这一步,总线上一条字段不全的记录会抛 KeyError,
# 把 poll 任务打死,进而**整个执行器停止交易**——一行坏数据换全面停摆,
# 代价完全不对等。搬运侧已挡半行,但挡不住字段级的不全
miss = [k for k in self.NEEDED if r.get(k) is None]
if miss:
self.n_skip += 1
print(f" ⊘ 丢弃畸形记录,缺字段 {miss}{str(r)[:200]}", flush=True)
log_trade({"ev": "malformed", "missing": miss, "rec": str(r)[:500]})
await tg.error("总线上有畸形记录,已丢弃",
f"缺字段 {miss}\n{str(r)[:300]}")
return
age = time.time() - r["emit_ms"] / 1000.0
n_open = sum(1 for v in self.execs.values() if v)
why = self.guard.blocks(r["key"], n_open)
# 同币占用检查。整套记账隐含「一个币最多一个仓位」这个前提,但它原先
# 只存在于口头上。同币开两笔时交易所会**净成一个仓位**,于是:
# · watch() 按 pair 判出场,两个 key 同时进 gone_match_hist 给它们
# 返回同一条历史记录,realized() 被调两次 → pnl_day 翻倍。而这正是
# MAX_DAY_LOSS 读的数:亏损翻倍会提前停机,盈利翻倍会让闸变迟钝
# · sweep() 在第一笔截止时平掉合并后的整个仓位,把第二笔才持有十分钟
# 的部分一并平掉
# 合并后的行为(一个止损、两个不同价位的止盈、超时一锅端)不是任何一版
# 回测建模的东西,所以跳过第二个信号是最接近安全的近似。期望并发 0.18
# 笔,这一跳损失极小
if why is None:
dup = [k for k, v in self.execs.items() if v["sym"] == r["sym"]]
if dup:
why = (f"{r['sym']} 已有在场仓位 {dup[0]}——同币开两笔会在"
f"交易所侧净成一个仓位,把盈亏记账和超时腿都搞错")
if why is None and age > STALE_S:
why = f"信号已过期 {age:.1f}s > {STALE_S:.0f}s"
if why:
self.n_skip += 1
print(f"{r['key']} 跳过:{why}", flush=True)
log_trade({"ev": "skip", "key": r["key"], "why": why,
"age_s": round(age, 2)})
# 只有被硬约束挡住才推。过期跳过是常态(搬运重连会重放旧信号),
# 推了会把真事淹掉
if "过期" not in why and "已做过" not in why:
await tg.blocked(r["key"], why)
return
self.guard.took(r["key"])
self.n_took += 1
lg = legs()
side = "LONG" if r["direction"] > 0 else "SHORT"
print(f"{r['key']} {side} 名义 {NOTIONAL:.0f} {LEVERAGE}x "
f"· 延后 {age:.1f}s · ATR {r['atr_pct'] * 1e4:.1f}bp",
flush=True)
for x in lg:
print(f" {x['tag']:<7}止盈 {x['atr']:.0f} ATR = "
f"{x['atr'] * r['atr_pct'] * 1e4:.1f}bp · 止损 "
f"{SL_ATR * r['atr_pct'] * 1e4:.1f}bp · 超时 {MAXB}min",
flush=True)
log_trade({"ev": "entry", "key": r["key"], "side": side,
"entry_px": r["entry_px"], "atr_pct": r["atr_pct"],
"notional": NOTIONAL, "leverage": LEVERAGE,
"age_s": round(age, 2), "legs": lg, "dry": self.dry})
# 空跑也要走完 open_position:数量取整、价位对齐 tick、请求体构造都在
# 那里,跳过等于什么都没验。不下真单由 Bitget(dry=True) 负责
await self.open_position(r, lg)
def qty_of(self, sym: str, entry: float) -> tuple[str, str]:
"""入场量与半仓量,都对齐步长。
入场量取到**步长的偶数倍**,半仓才是精确一半。不这么做 SOL 的半仓会是
全仓的 43%(步长 0.1 币 ≈ 10.7 USDT),而回测假设 50/50。
"""
r = self.rules.get(f"{sym}USDT")
if not r:
raise RuntimeError(f"{sym} 没有合约规则")
step = Decimal(str(r["sizeMultiplier"]))
px = Decimal(str(entry))
grid = step * 2
n = max(Decimal("1"),
(Decimal(str(NOTIONAL)) / px / grid).quantize(Decimal("1")))
qty = n * grid
return str(qty), str(qty / 2)
def snap(self, sym: str, px: float) -> str:
r = self.rules[f"{sym}USDT"]
tick = Decimal(str(r["priceEndStep"])) * (
Decimal(10) ** -int(r["pricePlace"]))
q = (Decimal(str(px)) / tick).quantize(Decimal("1")) * tick
return str(q)
async def open_position(self, r: dict, lg: list[dict]) -> None:
"""两笔「市价入场 + 服务端止损」,再各挂一个 maker 止盈。
止损随入场单一起到交易所(presetStopLossPrice),所以不存在"已入场、
止损未挂"的裸仓窗口——那是本地盯价方案最危险的一段。
止盈单独挂 post_only 限价:成本模型里止盈按 maker 计且不吃滑点,用
preset(触发后市价)会让这部分变成 taker,预算就不成立了。
"""
sym, d = r["sym"], r["direction"]
pair = f"{sym}USDT"
entry = r["entry_px"]
a = entry * r["atr_pct"]
_, half = self.qty_of(sym, entry)
side = "buy" if d > 0 else "sell"
close_side = "sell" if d > 0 else "buy"
hold = "long" if d > 0 else "short"
stop_px = self.snap(sym, entry - d * SL_ATR * a)
opened, failed = [], []
for x in lg:
oid = oid_of(r["key"], x["tag"])
try:
await self.api.entry_with_stop(pair, side, half, stop_px, oid)
except Exception as e:
print(f"{x['tag']} 入场失败 {e}", flush=True)
log_trade({"ev": "entry_fail", "key": r["key"],
"tag": x["tag"], "err": str(e)})
failed.append(f"{x['tag']} 入场:{e}")
continue
tp_px = self.snap(sym, entry + d * x["atr"] * a)
try:
await self.api.tp_limit(pair, close_side, half, tp_px,
oid + "_tp")
except Exception as e:
# 入场成了但止盈没挂上:仓位仍有服务端止损,不是裸仓。
# 超时腿会兜住它,所以只告警不强平
print(f"{x['tag']} 止盈挂单失败 {e}"
f"(仓位有服务端止损,超时腿会兜)", flush=True)
log_trade({"ev": "tp_fail", "key": r["key"],
"tag": x["tag"], "err": str(e)})
failed.append(f"{x['tag']} 止盈:{e}")
opened.append({"tag": x["tag"], "oid": oid, "size": half,
"tp_px": tp_px, "stop_px": stop_px})
print(f" {x['tag']:<7}{half} 币 · 止损 {stop_px} · "
f"止盈 {tp_px}", flush=True)
log_trade({"ev": "opened", "key": r["key"], "legs": opened,
"stop_px": stop_px})
# 下单失败必须推。这类失败是**静默的经济损失**:日志里在报,但表现只是
# "一直没开仓",看起来和"没信号"一样。实盘首日就因为这个白跑 5 小时——
# 8 次下单全被 40774 拒掉而无人知道。所以推送不是可选的
if failed:
self.n_order_fail += 1
if not opened:
await tg.error(
f"{r['key']} 建仓全部失败,这个信号丢了",
"\n".join(failed) +
f"\n\n累计失败 {self.n_order_fail} 次。"
f"链路正常但下不了单——常见是下单体字段被拒"
f"(40774 持仓模式不匹配)、保证金不足、或该币被限制交易")
else:
await tg.error(f"{r['key']} 部分腿失败,收益结构已偏离设计",
"\n".join(failed))
if opened:
self.n_built += 1
self.execs[r["key"]] = {
"sym": sym, "pair": pair, "hold": hold,
"deadline": time.time() + MAXB * 60, "legs": opened,
# watch() 靠这个时间戳去 history-position 里认领对应的平仓记录
"opened_ms": int(time.time() * 1000),
"side": "LONG" if d > 0 else "SHORT"}
await tg.opened(sym, self.execs[r["key"]]["side"], entry,
r["atr_pct"], NOTIONAL, LEVERAGE, stop_px,
opened, time.time() - r["emit_ms"] / 1000.0)
async def watch(self) -> None:
"""盯交易所侧的出场,把已实现盈亏记回闸。
为什么必须有这个循环:止损与止盈都挂在交易所侧,成交时本进程收不到
任何通知。缺了它有两个后果,都是静默的:
1. `Guard.realized()` 没人调用 → `pnl_day` 恒为 0 →
**MAX_DAY_LOSS 这道闸完全不生效**。三条硬约束里最重要的一条。
2. `self.execs` 的条目要挂到 48 分钟截止才清 → `MAX_OPEN` 把已经
出场的仓位继续算在场 → 新信号被白挡掉。方向保守但不是本意。
盈亏取交易所的 `netProfit`= pnl + 资金费 + 开平手续费),不自己按
标记价估——估会漏掉费用,而且方向总是偏乐观。
"""
while True:
await asyncio.sleep(WATCH_S)
if self.dry or not self.execs:
continue
try:
live = {p["symbol"] for p in await self.my_positions()}
except Exception as e: # noqa: BLE001
print(f" ⚠ 盯仓读持仓失败 {type(e).__name__}: {e}", flush=True)
continue
gone = [k for k, st in self.execs.items()
if st["pair"] not in live]
if not gone:
continue
# 两个半仓在同一 symbol 上会被交易所净成一个仓位,所以一个 key
# 对应一条历史记录。按最早的入场时间取一次历史,够覆盖全部
since = min(self.execs[k]["opened_ms"] for k in gone) - 60_000
try:
hist = await self.api.history_positions(start_ms=since)
except Exception as e: # noqa: BLE001
print(f" ⚠ 读历史持仓失败 {type(e).__name__}: {e}"
f"本轮不结算(下轮重试,不会漏)", flush=True)
continue
# 一条历史记录只能被一个 key 认领。同币并发已在 on_signal 拦住,
# 但记账是花钱的路径:让这个不变量在本地成立,而不是依赖两百行外
# 的另一处检查。重复认领会让 realized() 被调两次、pnl_day 翻倍
claimed: set = set()
for key in gone:
st = self.execs[key]
rec = self._match_hist(hist, st, claimed)
if rec is None:
# 常见于刚平掉、历史还没落库。留着下轮再试;真丢了也有
# 48 分钟截止那条路兜住 execs 的清理
print(f"{key} 已出场但历史未就绪,下轮再结算",
flush=True)
continue
pnl = float(rec.get("netProfit") or 0.0)
self.guard.realized(pnl)
self.guard.save()
self.execs.pop(key, None)
print(f"{key} 交易所侧出场 · 已实现 {pnl:+.2f} USDT "
f"· 当日累计 {self.guard.pnl_day:+.2f}", flush=True)
log_trade({"ev": "closed", "key": key, "net_profit": pnl,
"open_px": rec.get("openAvgPrice"),
"close_px": rec.get("closeAvgPrice")})
await tg.closed(st["sym"], st["side"], pnl,
float(rec.get("openAvgPrice") or 0),
float(rec.get("closeAvgPrice") or 0),
self.guard.pnl_day, self.guard.n_day)
@staticmethod
def _match_hist(hist: list, st: dict,
claimed: set | None = None) -> dict | None:
"""在历史持仓里认领属于这一笔的记录。
按 symbol + holdSide 匹配,并要求收盘时间不早于入场时间(减 60s 容差,
两边时钟与落库都有抖动)。同一 symbol 有多条时取最近的一条。
`claimed` 装已被别的 key 认走的 positionId,防止两个 key 认到同一条。
"""
best, best_t, best_id = None, -1.0, None
for r in hist:
if r.get("symbol") != st["pair"] or r.get("holdSide") != st["hold"]:
continue
pid = r.get("positionId")
if claimed is not None and pid is not None and pid in claimed:
continue
t = float(r.get("utime") or r.get("uTime") or 0)
if t < st["opened_ms"] - 60_000:
continue
if t > best_t:
best, best_t, best_id = r, t, pid
if best is not None and claimed is not None and best_id is not None:
claimed.add(best_id)
return best
async def sweep(self) -> None:
"""超时腿:48 分钟到点市价平。
这是唯一必须靠本进程存活的出场腿。止损与止盈都在交易所侧,所以进程
死掉只会让持仓超过 48 根,不会变成裸仓——退化是良性的。
"""
while True:
await asyncio.sleep(5)
now = time.time()
for key, st in list(self.execs.items()):
if now < st["deadline"]:
continue
try:
pos = [p for p in await self.my_positions()
if p["symbol"] == st["pair"]]
if not pos:
print(f"{key} 超时前已全部出场", flush=True)
log_trade({"ev": "timeout_noop", "key": key})
else:
for p in pos:
await self.api.cancel_all(st["pair"])
await self.api.close_market(
st["pair"], p["holdSide"], p["total"],
oid_of(key, "timeout"))
print(f"{key} 超时市价平 {pos[0]['total']}",
flush=True)
log_trade({"ev": "timeout_close", "key": key,
"size": pos[0]["total"]})
except Exception as e:
print(f"{key} 超时平仓失败 {type(e).__name__}: {e}",
flush=True)
continue
self.execs.pop(key, None)
def ship_state(self) -> dict | None:
"""读搬运器落的存活文件。读不到返回 None——那本身就是要报的事。"""
try:
p = self.bus.parent / "ship_alive.json"
with open(p, encoding="utf-8") as f:
return json.load(f)
except Exception: # noqa: BLE001
return None
async def heartbeat(self) -> None:
while True:
await asyncio.sleep(300)
if TG_HB_MIN and time.time() - self.tg_hb_at >= TG_HB_MIN * 60:
self.tg_hb_at = time.time()
await tg.alive(time.time() - self.t0, self.n_seen,
self.n_built, self.n_took,
sum(1 for v in self.execs.values() if v),
MAX_OPEN, self.guard.n_day, MAX_DAY,
self.guard.pnl_day, MAX_DAY_LOSS,
self.n_order_fail, self.ship_state(), self.dry)
# 跨日结算是 roll() 里同步留下的,在这里发出去
self.guard.roll()
if self.guard.pending_roll:
await tg.day_rolled(*self.guard.pending_roll)
self.guard.pending_roll = None
n_open = sum(1 for v in self.execs.values() if v)
# 「已做 N 但建仓 0」是明确的故障,不能只把数字并排列出来让人自己
# 看。首日那 5 小时的心跳里 "已做 4 · 在场 0/3" 一直在打,但没有
# 任何一处说这是异常
bad = ""
if self.n_took and not self.n_built:
bad = f" ⛔ 做了 {self.n_took} 笔却一次都没建上,下单被拒"
elif self.n_order_fail:
bad = f" ⚠ 有 {self.n_order_fail} 次下单失败"
print(f" [心跳] 见信号 {self.n_seen} · 已做 {self.n_took} · "
f"建仓 {self.n_built} · 跳过 {self.n_skip} · "
f"在场 {n_open}/{MAX_OPEN} · "
f"当日 {self.guard.n_day}/{MAX_DAY} 笔 · "
f"当日 PnL {self.guard.pnl_day:+.2f}/-{MAX_DAY_LOSS}{bad}",
flush=True)
async def shutdown(self) -> None:
"""收到停机信号:撤挂单 + 平掉在场仓位,然后退出。
为什么停机要平仓,而崩溃不需要:崩溃后 systemd/docker 会在几秒内重启,
`reconcile` 接着就把遗留仓位清掉,空窗期有交易所侧止损兜着。而**主动
停机后没人重启**,仓位会一直挂到止损或止盈——48 分钟超时腿丢了,跑的
就不是回测那个出场结构了。
直接复用 reconcile:它做的正是"撤挂单 + 市价平掉一切"
"""
print("\n 收到停机信号,撤挂单并平掉在场仓位", flush=True)
await tg.stopping(len(self.execs))
try:
if self.dry:
print(" 空跑模式,无仓位可平", flush=True)
else:
await asyncio.wait_for(self.reconcile(), timeout=60.0)
except asyncio.TimeoutError:
print(" ⛔ 平仓超过 60s 未完成。仓位仍有交易所侧止损,"
"但超时腿已丢,去交易所确认", flush=True)
except Exception as e: # noqa: BLE001
print(f" ⛔ 停机平仓失败 {type(e).__name__}: {e},需人工介入",
flush=True)
finally:
# 不关会话会在日志里留 "Unclosed client session",且反复重启
# Restart=always)会漏 socket
try:
await self.api.close()
except Exception: # noqa: BLE001
pass
async def run(self) -> None:
await self.start()
stop = asyncio.Event()
loop = asyncio.get_running_loop()
# 必须显式挂 SIGTERMdocker stop 与 systemd stop 默认发的都是它,
# 而 Python 对 SIGTERM 不抛 KeyboardInterrupt,不挂就是直接消失、
# 没有任何清理。SIGINT 一并挂上,省得依赖 KillSignal= 那种绕法
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop.set)
work = [asyncio.create_task(c)
for c in (self.poll(), self.watch(), self.sweep(),
self.heartbeat())]
done, _ = await asyncio.wait(
[*work, asyncio.create_task(stop.wait())],
return_when=asyncio.FIRST_COMPLETED)
for t in work:
t.cancel()
await asyncio.gather(*work, return_exceptions=True)
# 任一主循环自己退出(异常)也走这里:仓位不能留给没人管的进程
for t in done:
if t in work and (exc := t.exception()) is not None:
print(f" ⛔ 主循环异常退出 {type(exc).__name__}: {exc}",
flush=True)
await tg.error("主循环异常退出,正在平仓并退出",
f"{type(exc).__name__}: {exc}")
await self.shutdown()
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true",
help="不连交易所、不下单,只验总线与约束逻辑")
ap.add_argument("--bus", default=str(signal_bus.BUS))
a = ap.parse_args()
print(f"实盘执行器 · 名义 {NOTIONAL:.0f} USDT · {LEVERAGE}x · "
f"并发≤{MAX_OPEN} · 日开仓≤{MAX_DAY} · 日亏损≤{MAX_DAY_LOSS}")
asyncio.run(Exec(a.dry_run, Path(a.bus)).run())
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
# 生产执行器的全部依赖。**保持这个文件只有一行。**
#
# 每加一个包都要能回答"实盘进程崩在这个包里我怎么办"。numpy/pandas/pyarrow
# 曾经因为读两个常量被拖进来(见 live/exit_params.py 的说明),已经切掉。
#
# 版本下限的理由:3.9 起 aiohttp 才在 Python 3.12+ 上稳定编译;不锁上限是
# 因为这里只用 ClientSession.request 这一个最稳定的 API 面。
aiohttp>=3.9
+232
View File
@@ -0,0 +1,232 @@
"""把产信号那台机的总线搬到本机(生产机)。跑在**消费侧**,即 AWS 上。
## 为什么要搬
Bitget 的 API key 绑了 IP 白名单,只能从 AWS 那台发单;而信号是新加坡那台
采集器算出来的。执行器不自己算信号的理由见 `signal_bus.py` 顶部——最要紧的
是「实盘交易的必须是影子测量的那一个信号」,各算一份会悄悄分叉。
## 为什么是拉而不是推
拉的一侧是生产机,它对自己的输入负责。推的话,研究机上一个脚本挂了就会静默
断供,而生产机看不出区别(信号本来就 6.8 个/天,长时间没有是正常的)。
## 断线怎么自愈
每次重连都 `tail -c +0`,即从文件头重放全部内容,本地按 `key` 去重后只追加
新的。所以断线期间产生的信号会在重连时补齐,不需要记录偏移量。
⚠️ 补齐**不等于**补做:重放上来的旧信号会被 `live_exec` 的 `LIVE_STALE_S`
(默认 20s)挡掉。这是对的——参考成交价是次根开盘价,过了就不是回测那个价。
所以断线超过 20s 就等于漏掉那些信号,这是可接受的退化,不是 bug。
## 为什么单独一个进程
搬运挂掉时,执行器要继续管在场仓位(48 分钟超时平仓在本进程里)。合成一个
进程会让传输故障连坐到仓位管理。
python live/ship_signals.py --from sg-collector # 用 ~/.ssh/config 的别名
python live/ship_signals.py --from user@1.2.3.4 --remote-bus /home/user/chan-live/state/signals_live.jsonl
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parent))
import signal_bus # noqa: E402
# ssh 参数的理由:
# BatchMode 不要交互提示密码,否则进程会挂在那里等输入
# ServerAliveInterval/CountMax 45s 内探测不到就断开重连。没有这两条,
# NAT 静默丢弃连接后 tail 会永远挂着不返回,表现为
# 「进程活着但再也收不到信号」——最难发现的那种故障
# ExitOnForwardFailure/StrictHostKeyChecking 留默认,主机指纹要人工确认过
SSH_OPTS = ["-T", "-o", "BatchMode=yes",
"-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3",
"-o", "ConnectTimeout=10"]
REMOTE_BUS = os.environ.get(
"SHIP_REMOTE_BUS", "~/chan-live/state/signals_live.jsonl")
BACKOFF_MAX = 60.0
# 允许的负龄。1s 覆盖正常的 NTP 抖动与网络传输,超出就该当时钟问题查
SKEW_TOL_S = 1.0
class Shipper:
def __init__(self, host: str, remote_bus: str, local_bus: Path) -> None:
self.host = host
self.remote_bus = remote_bus
self.bus = local_bus
self.seen: set[str] = set()
self.n_new = 0
self.n_dup = 0
self.connected_at = 0.0
self.last_signal_ts = 0.0
self.n_reconnect = 0
self.n_skew = 0
self.ssh_up = False
self.alive = local_bus.parent / "ship_alive.json"
def load_seen(self) -> None:
"""本地已有的键先读进来,避免重启后把整个文件再追加一遍。"""
self.bus.parent.mkdir(parents=True, exist_ok=True)
for rec in signal_bus.read_all(self.bus):
k = rec.get("key")
if k:
self.seen.add(k)
print(f" 本地已有 {len(self.seen)} 条信号,按 key 去重", flush=True)
def absorb(self, line: str) -> None:
line = line.strip()
if not line:
return
try:
rec = json.loads(line)
except json.JSONDecodeError:
# 半行:tail 在写入中途读到。重连重放时会拿到完整的那一行
print(f" ⚠ 跳过无法解析的一行({len(line)} 字节)", flush=True)
return
k = rec.get("key")
if not k:
print(f" ⚠ 跳过无 key 的记录:{line[:80]}", flush=True)
return
if k in self.seen:
self.n_dup += 1
return
self.seen.add(k)
self.n_new += 1
self.last_signal_ts = time.time()
# 原样追加,不重新序列化——保持与源文件逐字节一致,便于事后对账
with self.bus.open("a", encoding="utf-8") as f:
f.write(line + "\n")
f.flush()
os.fsync(f.fileno())
age = time.time() - rec.get("kline_ts", 0) / 1000.0
if age < -SKEW_TOL_S:
# 负龄说明产信号那台机的时钟快于本机。这不是无害的:staleness 闸
# 靠 age 判断,时钟快 5 分钟就等于把闸放宽 5 分钟,一个早已失效的
# 参考价会被当成新鲜的照做。两台都必须挂 NTP(部署文档里是硬要求)
self.n_skew += 1
print(f"{k} 时间倒流 {-age:.1f}s —— 两机时钟不同步,"
f"staleness 闸已不可信。查 chronyd/systemd-timesyncd",
flush=True)
mark = "" if age <= 20 else " ⚠ 已超 20s,执行器会挡掉"
print(f" ▶ 收到 {k} · 距参考价成立 {age:.1f}s{mark}", flush=True)
async def pump(self) -> None:
"""连一次,读到断为止。返回即表示需要重连。"""
cmd = ["ssh", *SSH_OPTS, self.host,
f"tail -c +0 -F {self.remote_bus}"]
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE)
self.connected_at = time.time()
self.ssh_up = True
self.touch_alive(0) # 立刻落盘,别等 5 分钟心跳——执行器第一轮
# 整点推送会读这个文件,晚写就会误报上游断了
print(f" ssh 已连上 {self.host}", flush=True)
assert proc.stdout is not None
try:
async for raw in proc.stdout:
self.absorb(raw.decode("utf-8", "replace"))
finally:
err = b""
if proc.stderr is not None:
try:
err = await asyncio.wait_for(proc.stderr.read(), 2.0)
except asyncio.TimeoutError:
pass
if proc.returncode is None:
proc.kill()
await proc.wait()
up = time.time() - self.connected_at
self.ssh_up = False
self.touch_alive(up) # 立刻标断开。只靠停更来发现的话,心跳还在
# 刷 ts,执行器会以为管道还活着
msg = err.decode("utf-8", "replace").strip()
print(f" ssh 断开(在线 {up:.0f}s,退出码 {proc.returncode}"
f"{'' + msg if msg else ''}", flush=True)
async def run(self) -> None:
self.load_seen()
asyncio.create_task(self.heartbeat())
backoff = 1.0
while True:
try:
await self.pump()
backoff = 1.0 # 正常断开:立刻重连
except Exception as e: # noqa: BLE001
print(f" ⚠ 搬运出错:{type(e).__name__}: {e}", flush=True)
self.n_reconnect += 1
await asyncio.sleep(backoff)
backoff = min(backoff * 2, BACKOFF_MAX)
async def heartbeat(self) -> None:
"""信号 6.8 个/天,所以「很久没收到」是正常的,不能当健康指标。
真正要报的是**连接**在不在:ssh 在线时长与重连次数。管道死了但进程
活着是这里最危险的状态,ServerAliveInterval 负责让它变成一次断开。
"""
while True:
await asyncio.sleep(300)
up = time.time() - self.connected_at if self.connected_at else 0
last = (f"{(time.time() - self.last_signal_ts) / 60:.0f} 分钟前"
if self.last_signal_ts else "本次启动后还没有")
skew = f" · ⛔ 时钟倒流 {self.n_skew}" if self.n_skew else ""
print(f" [心跳] ssh 在线 {up / 60:.0f} 分钟 · 重连 "
f"{self.n_reconnect} 次 · 新增 {self.n_new}"
f"(重放去重 {self.n_dup})· 最近一条 {last}{skew}",
flush=True)
self.touch_alive(up)
def touch_alive(self, up: float) -> None:
"""把连接状态落到文件,供执行器的整点推送读。
为什么要落盘:Telegram 推送在执行器那侧,而它看不到本进程的日志。
「执行器活着」单独没有意义——搬运管道死掉时执行器一样心跳正常、一样
什么都不做,那正是最危险的状态。所以推送里必须带上游的新鲜度,
这个文件是唯一的传递途径。
写失败只打日志:搬运的正事是投信号,不能因为写不了状态文件而中断。
"""
try:
self.alive.parent.mkdir(parents=True, exist_ok=True)
tmp = self.alive.with_suffix(".tmp")
tmp.write_text(json.dumps({
"ts": time.time(), "up_s": round(up),
"connected": self.ssh_up,
"n_reconnect": self.n_reconnect, "n_new": self.n_new,
"n_skew": self.n_skew,
"last_signal_ts": self.last_signal_ts}), encoding="utf-8")
tmp.replace(self.alive) # 原子替换,读侧不会看到半个文件
except Exception as e: # noqa: BLE001
print(f" ⚠ 写存活文件失败 {type(e).__name__}: {e}", flush=True)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--from", dest="host", required=True,
help="产信号那台机的 ssh 目标,如 sg-collector 或 user@ip")
ap.add_argument("--remote-bus", default=REMOTE_BUS,
help="对端总线路径(对端 shell 展开,可用 ~)")
ap.add_argument("--bus", default=str(signal_bus.BUS),
help="本机总线路径,执行器读同一个")
a = ap.parse_args()
s = Shipper(a.host, a.remote_bus, Path(a.bus))
print(f"信号搬运:{a.host}:{a.remote_bus}{a.bus}", flush=True)
try:
asyncio.run(s.run())
except KeyboardInterrupt:
print(" 停止", flush=True)
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
"""影子把过滤网的信号写到这里,实盘执行器读这里。
## 为什么不让实盘自己算信号
三个理由,第三个最要紧:
1. 2 核上再来一份十币计算,清空会从 247ms 推到 500ms+
2. 实盘进程崩溃不该影响正在采的数据集
3. **实盘交易的必须是影子测量的那一个信号。** 各算一份会让两边悄悄分叉,
之后就没法把实盘的实际成交和影子测的滑点曲线对照——而那个对照是整件事
的目的
## 为什么用 append-only 文件而不是队列
崩溃安全 + 留审计轨迹。实盘进程重启后能从文件里看到自己漏掉了哪些信号,
而不是像内存队列那样直接消失。文件也让"影子在跑、实盘没在跑"这种状态成为
可观测的(信号在攒着),而不是静默丢弃。
每行一个 JSON,字段见 `emit`。`key` 是幂等键,实盘按它去重——同一根被重复
处理(补根、进程池重建后重放)不该开两次仓。
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
# 默认不落在仓库里:git checkout/clean 会动仓库,而这里是跨进程(甚至跨机)
# 的交接点,被清掉就等于信号静默丢失。产信号的一侧和消费的一侧各自指到
# 自己的路径即可,同机时指到同一个文件
BUS = Path(os.environ.get(
"SIGNAL_BUS", Path.home() / "chan-live" / "state" / "signals_live.jsonl"))
def key_of(sym: str, kline_ts: int, direction: int) -> str:
return f"{sym}:{int(kline_ts)}:{int(direction):+d}"
def emit(sym: str, kline_ts: int, direction: int, entry_px: float,
atr_pct: float, lag_ms: float, path: Path | None = None) -> None:
"""追写一条信号。任何失败只打日志——总线写不进去不能连坐采集。
`entry_px` 是次根开盘价,也就是回测口径的成交价。实盘据此算止损/止盈的
绝对价位,**不要**用实盘自己看到的现价,否则价位会随执行延迟漂移,跑的
就不是回测那个结构。
"""
p = path or BUS
rec = {"key": key_of(sym, kline_ts, direction),
"sym": sym, "kline_ts": int(kline_ts),
"direction": int(direction),
"entry_px": float(entry_px), "atr_pct": float(atr_pct),
"lag_ms": float(lag_ms),
"emit_ms": int(time.time() * 1000)}
try:
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("a") as f:
f.write(json.dumps(rec) + "\n")
f.flush()
# 实盘要在毫秒级看到,且进程被 SIGKILL 时不能丢——这两点都要求
# 落到磁盘,不能只停在 libc 缓冲里
os.fsync(f.fileno())
except Exception as e:
print(f" [bus] 写信号失败 {type(e).__name__}: {e}", flush=True)
def read_all(path: Path | None = None):
"""读全部信号。坏行跳过——半行只可能出现在文件末尾的崩溃点。"""
p = path or BUS
if not p.exists():
return
for line in p.read_text().splitlines():
if not line.strip():
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
+172
View File
@@ -0,0 +1,172 @@
"""生产侧 Telegram 通知。只用标准库 + aiohttp。
## 推什么、不推什么
推送的价值在于**你会因此做点什么**。按这个标准筛:
推 开仓 能立刻眼看一遍方向/价位对不对
推 平仓 带已实现盈亏(含手续费与资金费),这是唯一的真账
推 闸拦截 仅 MAX_OPEN / MAX_DAY / MAX_DAY_LOSS —— 说明有 bug 或策略在流血
推 报错、对账平仓、跨日结算
推 整点在线 每 60 分钟一条,见下
不推 信号过期跳过 这是常态(重连重放会带上旧信号),推了就淹掉真事
不推 5 分钟心跳 日志里有,推了每天 288 条
量级:信号 6.8 个/天、日开仓上限 15、在线 24 条,所以最多约 55 条/天。
## 整点在线那条为什么不违反上面的标准
只说「我还活着」的推送看两天就会被忽略,那时它就成了噪声。所以这条必须带
**能暴露问题的数字**,尤其是上游新鲜度——执行器活着不代表链路活着,搬运
管道死掉时执行器一样心跳正常、一样什么都不做,那是最危险的状态。异常时这
条会显式标出来,而不是把数字并排列出来让人自己看。
## 失败一律只打日志
推送挂了不能连坐交易。所有异常在这里吞掉——上层不该因为 Telegram 抽风而
影响下单或平仓。
"""
from __future__ import annotations
import os
import time
TOKEN = os.environ.get("TG_TOKEN", "")
CHAT = os.environ.get("TG_CHAT", "")
ENABLED = bool(TOKEN and CHAT)
# 生产机上给这条推送打个前缀,免得和采集机推的手工信号混在一个对话里分不清
TAG = os.environ.get("TG_TAG", "实盘")
async def send(text: str) -> None:
"""推一条。任何失败都只打日志——推送挂了不能连坐交易。"""
if not ENABLED:
return
try:
import aiohttp
url = f"https://api.telegram.org/bot{TOKEN}/sendMessage"
async with aiohttp.ClientSession() as s:
async with s.post(url,
json={"chat_id": CHAT, "text": text},
timeout=aiohttp.ClientTimeout(total=10)) as r:
if r.status != 200:
body = (await r.text())[:200]
print(f" [TG] 推送失败 HTTP {r.status} {body}", flush=True)
except Exception as e: # noqa: BLE001
print(f" [TG] 推送异常 {type(e).__name__}: {e}", flush=True)
def _fmt(px: float) -> str:
"""按量级选小数位。跨币种从 79000 到 0.087,固定位数会难读或丢精度。"""
a = abs(px)
if a >= 1000:
return f"{px:.1f}"
if a >= 10:
return f"{px:.3f}"
if a >= 1:
return f"{px:.4f}"
return f"{px:.6f}"
async def opened(sym: str, side: str, entry: float, atr_pct: float,
notional: float, leverage: int, stop_px: str,
legs: list[dict], age_s: float) -> None:
arrow = "" if side == "LONG" else ""
lines = [f"[{TAG}] 开仓 {sym} {arrow}",
f"参考价 {_fmt(entry)} · ATR {atr_pct * 1e4:.1f}bp",
f"名义 {notional:.0f} USDT · {leverage}x · "
f"保证金 {notional / leverage:.0f} USDT",
f"止损 {stop_px}(两腿共用,不移动)"]
for lg in legs:
lines.append(f" {lg['tag']:<6} {lg['size']} 币 · 止盈 {lg['tp_px']}")
lines.append(f"距参考价成立 {age_s:.1f}s")
await send("\n".join(lines))
async def closed(sym: str, side: str, pnl: float, open_px: float,
close_px: float, day_pnl: float, day_n: int,
reason: str = "") -> None:
arrow = "" if side == "LONG" else ""
mark = "" if pnl > 0 else ("" if pnl < 0 else "")
tail = f" · {reason}" if reason else ""
await send(f"[{TAG}] 平仓 {sym} {arrow} {mark} {pnl:+.2f} USDT{tail}\n"
f"{_fmt(open_px)} → 平 {_fmt(close_px)}\n"
f"当日 {day_n} 笔 · 累计 {day_pnl:+.2f} USDT")
async def blocked(key: str, why: str) -> None:
"""只在被硬约束挡住时推。过期跳过属于常态,不走这里。"""
await send(f"[{TAG}] ⛔ 信号被挡 {key}\n{why}")
async def error(what: str, detail: str) -> None:
await send(f"[{TAG}] ⛔ {what}\n{detail[:500]}")
async def started(notional: float, leverage: int, syms: int,
dry: bool) -> None:
mode = "空跑(不下真单)" if dry else "真跑"
await send(f"[{TAG}] 执行器启动 · {mode}\n"
f"名义 {notional:.0f} USDT · {leverage}x · {syms}")
async def day_rolled(day: str, n: int, pnl: float) -> None:
await send(f"[{TAG}] {day} 结算 · {n} 笔 · {pnl:+.2f} USDT")
def _dur(s: float) -> str:
s = max(0, int(s))
if s < 60:
return f"{s}"
if s < 3600:
return f"{s // 60}分钟"
if s < 86400:
h, m = s // 3600, (s % 3600) // 60
return f"{h}小时{m}" if m else f"{h}小时"
return f"{s / 86400:.1f}"
async def alive(up_s: float, seen: int, built: int, took: int, n_open: int,
max_open: int, day_n: int, max_day: int, day_pnl: float,
max_loss: float, order_fail: int, ship: dict | None,
dry: bool) -> None:
"""整点在线。异常在第一行,正常时才是「在线」。
`ship` 是搬运器落的 ship_alive.json 解出来的字典,None 表示读不到——
那本身就是要报的事:搬运没在跑、或者跑的是没有这个文件的旧版本。
"""
warn = []
if order_fail and not built:
warn.append(f"⛔ 做了 {took} 笔却一次都没建上,下单全被拒")
elif order_fail:
warn.append(f"⚠ 累计 {order_fail} 次下单失败")
if ship is None:
warn.append("⛔ 读不到搬运状态,信号可能根本没在进来")
else:
gap = time.time() - ship.get("ts", 0)
# 搬运连上/断开/每 5 分钟都会落一次。超过 12 分钟是进程自己死了
if gap > 720:
warn.append(f"⛔ 搬运状态已停更 {_dur(gap)},上游可能已断")
elif ship.get("connected") is False:
warn.append("⛔ 搬运 ssh 已断开,正在重连")
if ship.get("n_skew"):
warn.append(f"⛔ 搬运侧时钟倒流 {ship['n_skew']}")
head = warn[0] if warn else ("在线(空跑)" if dry else "在线")
lines = [f"[{TAG}] {head} · 已跑 {_dur(up_s)}",
f"信号 {seen} · 建仓 {built} · 在场 {n_open}/{max_open}",
f"当日 {day_n}/{max_day} 笔 · 盈亏 {day_pnl:+.2f}/-{max_loss:.1f}"]
if ship is not None:
last = ship.get("last_signal_ts") or 0
lines.append(
f"搬运 ssh 在线 {_dur(ship.get('up_s', 0))} · "
f"重连 {ship.get('n_reconnect', 0)} 次 · 最近信号 "
+ (f"{_dur(time.time() - last)}" if last else "启动后还没有"))
lines += warn[1:]
await send("\n".join(lines))
async def stopping(n_open: int) -> None:
await send(f"[{TAG}] 收到停机信号,平掉在场 {n_open} 笔后退出。"
f"\n注意:停机后不再有超时平仓与新开仓。"
f"发生时间 {time.strftime('%H:%M:%S')}")
+1268 -23
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
# 已 superseded
v0 改走免费交易所,由 **data_provider** 拉,chan 只从中转取。
见 [PROMOTE_deriv_relay.md](./PROMOTE_deriv_relay.md)。
CoinGlass 付费档等看完交易所效果再开,不要和本阶段混做。
+94
View File
@@ -0,0 +1,94 @@
# 需求:资金面数据(data_provider → chan
**提出方:** chan
**执行方:** data_provider
**阶段:** Paper。先看效果。不进 Live。
---
## 要什么
chan 要在缠论图上叠资金面,和现有 K 线对得上。
data_provider 对外提供资金面;chan **只从 data_provider 取**,不访问交易所,不访问 CoinGlass。
K 线路径不动(现有 `/api/candles` 与 K 线推送)。本次只加资金面。
---
## 数据
先三个币:**BTC、ETH、SOL**(USDT 永续,符号与现有 K 线相同,如 `BTC/USDT:USDT`)。
要两样:
1. **持仓量(OI**
- 要历史,能覆盖缠论常用周期:`15m``30m``4h``1d`(有 `1h`/`2h` 更好)。
- 要当前最新值。
- 历史长度至少约 30 天。
2. **资金费率(funding**
- 要当前值。
- 要历史结算序列。
- 对齐到各周期 K 线:结算点落到所在那根;非结算 bar 沿用上一次结算值,不要插值编造。
来源:交易所公开数据即可,本阶段不买 CoinGlass。OI 历史哪家所没有,用另一家所公开数据补,需标明来源。
**本阶段不要:** 清算、热力图、多空比、订单簿、CoinGlass。
---
## 给 chan 的接口
`/api/candles` 同一套约定:
- `symbol` 与蜡烛相同
- 时间戳毫秒 UTC
- 按周期 `tf` 取序列
- 支持 `start` / `end` / `limit`(默认 `limit=500`
示例:
```http
GET /api/deriv?symbol=BTC/USDT:USDT&tf=15m&metrics=oi,funding
```
每根:
| 字段 | 要求 |
|---|---|
| `timestamp` | 与同 `tf``/api/candles` **开盘时间**对齐;对不齐的不要 |
| `oi` | 该 bar 持仓量;缺则 `null` |
| `oi_src` | 该值来自哪家所 |
| `funding` | 该 bar 资金费率;缺则 `null` |
| `funding_src` | 该值来自哪家所 |
健康状态要能看出:资金面是否可用、各所是否通、上次成功时间。
---
## 约束
- 全程 HTTPS REST。本阶段不要求资金面 WebSocket。
- chan、浏览器不得直连交易所。
- 现有 K 线接口行为不变。
- 一家所挂了:缺那家字段,另一家仍要能出;两边都没有且无可用数据时明确失败。
- 上游限流或超时:不要拖垮 K 线。
---
## 不算本次
- CoinGlass / 付费数据
- 清算、热力、多空
- Live、下单
- chan 叠图(等本接口可用再做)
---
## 怎样算齐
1. `GET /api/deriv?symbol=BTC/USDT:USDT&tf=15m` 能拿到 `oi``funding`,时间能对上同参数的 `/api/candles`
2. chan / 浏览器零次访问交易所。
3. 只挂一家所时,接口仍可用,只缺对应字段。
4. K 线不受影响。
+225
View File
@@ -0,0 +1,225 @@
"""快速一类买卖点(fast B1/S1)——把引擎的 B1/S1 改写成当根可判的形式。
**动机**step55 实测引擎 `find_all_bsp` B1/S1 5m/15m 上是重亏的
PF 0.20~0.24胜率 15~21%t 17~22而且原因是几何性的引擎在
`leave_bi.sure_time` 才发信号中位滞后 8~9 此时价格已朝反弹方向跑了
1.68 ATR逆势信号上这个滞后是加倍惩罚新入场价往下 2 ATR 的止损落在
比原始低点还低 0.3 ATR 几乎贴着极值
B3 当年也是同样的病PF 0.66解法不是调参而是**重新定义成实时判据**
B4 `chanlun/analysis/fast_bsp.py`本模块对一类做同样的事
引擎 B1 是三个条件的合取`bsp.py: find_all_bsp` + `check_bi_div`
中枢已成 -> 一笔向下离开中枢 -> 该笔 MACD 面积 < 进入笔面积底背驰
三条都有当根可判的代理滞后来源只有一处`leave_bi.is_sure`
中枢已成 zs.is_sure -> zones.available_ts B4 同口径已解决
向下离开 leave_bi 端点在 zd -> **收盘** < zd当根可判
背驰 整笔面积对比 -> 进入笔面积在中枢确认时已是历史
离开段面积从突破根起逐根累加当根可判
触发 等笔确认滞后之源 -> 收盘反向越过前一根极值沿用 B4 的转强判据
**与三类的方向关系相反**三类顺着突破方向做一类逆着做中枢向下突破后
B4 会做空 fast B1 是在同一段下跌里找力竭然后**做多**
先验不利落地前请先看数§3.391 实测 `mom60` 最高的四分位是最差的
余量只剩 1.44bp而一类按定义就住在一段已走完的行情末端本模块是用来
证伪这个先验的不是用来假定它不成立的
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from chanlun.analysis.fast_bsp import _resolve_avail_bi, timestamps_ms
def zones_with_enter_area(zs_list, src: pd.DataFrame,
avail_bi: int | None = None) -> pd.DataFrame:
"""区间表 + 进入笔的 MACD 面积与方向。
这两列**不引入滞后**进入笔是中枢第一笔的前一笔中枢确认时它早已收尾
背驰判据里唯一需要等待的是离开段而那一段可以逐根累加
面积口径必须和 `ChanBI.cal_macd_hist` 一致只累加顺方向那一侧的 hist
并取绝对值上升笔累加正值下降笔累加负值的绝对值故恒非负
"""
ts_of = dict(zip(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"),
timestamps_ms(src)))
i = _resolve_avail_bi(avail_bi)
rows = []
for zs in zs_list:
bis = getattr(zs, "bi_list", [])
if not bis:
continue
key_bi = bis[i] if (i == -1 or len(bis) > i) else bis[-1]
avail = (ts_of.get(str(getattr(key_bi, "sure_time", "") or ""))
or ts_of.get(str(getattr(key_bi, "end_time", "") or "")))
if avail is None:
continue
enter_bi = getattr(bis[0], "pre", None)
if enter_bi is None:
continue
# dir 用 +1/−1 表示,避免把引擎枚举漏进研究侧
e_dir = 1 if str(enter_bi.dir).endswith("UP") else -1
rows.append({
"zg": float(zs.zg), "zd": float(zs.zd),
"start_ts": ts_of.get(str(bis[0].start_time), avail),
"available_ts": int(avail),
"enter_area": abs(float(enter_bi.macd_hist)),
"enter_dir": e_dir,
})
out = pd.DataFrame(rows)
if out.empty:
return out
return out.sort_values("available_ts").reset_index(drop=True)
def find_fast_bsp1(
df: pd.DataFrame,
zones: pd.DataFrame,
scan: int = 200,
leave_win: int = 60,
min_leave_bars: int = 3,
max_div: float = 1.0,
max_per_zone: int = 1,
diag: dict | None = None,
) -> pd.DataFrame:
"""扫描每个中枢,找「离开中枢后力度背驰、随即转向」的入场点。
zones 需含 zg / zd / available_ts / enter_area / enter_dir
`zones_with_enter_area` 的输出
`min_leave_bars` 要求离开段至少走这么多根才允许触发没有它的话转强判据
会在突破后第一根小反弹就打进去那时离开段的面积还没累起来背驰条件几乎
自动成立会退化成随便一个反弹就抄底
`max_div` 是背驰的松紧离开段面积 / 进入段面积 < max_div 才算背驰
1.0 等于引擎的 `macdhist_div < 0`调小则只认力度衰减更明显的
返回列
entry_idx 实时可下单的K线
direction +1 一买 / 1 一卖**与突破方向相反**
bo_idx 离开中枢的突破根
ext_idx 离开段的极值根
lag entry_idx ext_idx即相对理想入场点的滞后
div 离开段面积 / 进入段面积越小背驰越强
depth 离开段极值越过中枢边界的幅度相对边界
"""
need = {"zg", "zd", "available_ts", "enter_area", "enter_dir"}
if zones.empty or not need <= set(zones.columns):
return pd.DataFrame()
ts = df["timestamp"].to_numpy()
close = df["close"].to_numpy(float)
high = df["high"].to_numpy(float)
low = df["low"].to_numpy(float)
hist = df["macdhist"].to_numpy(float)
atr = df["atr"].to_numpy(float)
n = len(df)
rows = []
def note(key: str) -> None:
if diag is not None:
diag[key] = diag.get(key, 0) + 1
for zone_i, (_, z) in enumerate(zones.iterrows()):
note("中枢总数")
zg, zd = float(z["zg"]), float(z["zd"])
e_area, e_dir = float(z["enter_area"]), int(z["enter_dir"])
if zg <= zd or not np.isfinite(e_area) or e_area <= 0:
note("×无效中枢或进入段无面积")
continue
start = int(np.searchsorted(ts, z["available_ts"], side="left"))
if start >= n - 2:
note("×中枢太靠后")
continue
scan_end = min(start + scan * max_per_zone, n)
cursor = start
for occ in range(1, max_per_zone + 1):
if cursor >= n - 2:
break
was_inside = False
bo_idx, d = None, 0
for j in range(cursor, scan_end):
c = close[j]
if zd <= c <= zg:
was_inside = True
continue
if not was_inside:
continue
bo_idx, d = j, (1 if c > zg else -1)
break
if bo_idx is None:
if occ == 1:
note("×窗口内未离开中枢")
break
# 引擎要求 enter_bi.dir == leave_bi.dir 才比面积,否则背驰无从谈起
if e_dir != d:
note("×进入段与离开段不同向")
cursor = bo_idx + 1
continue
area = 0.0
ext = low[bo_idx] if d == -1 else high[bo_idx]
ext_idx = bo_idx
entry_idx, div_at = None, np.nan
for j in range(bo_idx, min(bo_idx + leave_win + 1, n)):
h = hist[j]
# 与 ChanBI.cal_macd_hist 同口径:只取顺方向一侧,取绝对值
if d == -1 and h < 0:
area -= h
elif d == 1 and h > 0:
area += h
if (low[j] < ext) if d == -1 else (high[j] > ext):
ext, ext_idx = (low[j] if d == -1 else high[j]), j
if j < bo_idx + min_leave_bars:
continue
# 收盘回到中枢内 -> 这不是一次有效的离开,弃掉
if zd <= close[j] <= zg:
note("×离开段收盘回到中枢内")
break
if area >= e_area * max_div:
continue # 力度未衰减,不是背驰
go = close[j] > high[j - 1] if d == -1 else close[j] < low[j - 1]
if go:
entry_idx, div_at = j, area / e_area
break
if entry_idx is None:
if occ == 1:
note("×未在窗口内背驰转向")
cursor = bo_idx + 1
continue
if occ == 1:
note("√成交")
edge = zd if d == -1 else zg
# 延伸度用 ATR 归一,才和 step62 的 ext_run 同口径 —— 那里实测它是
# 唯一单调区分「趋势末端 vs 趋势中途」的特征(命中率 12%→44%)。
# 用相对边界的百分比会混入价格水平,不同币之间不可比。
a_ext = atr[ext_idx]
rows.append({
"entry_idx": entry_idx, "direction": -d,
"bo_idx": bo_idx, "ext_idx": ext_idx,
"lag": entry_idx - ext_idx,
"div": div_at,
"ext_atr": (abs(ext - edge) / a_ext
if np.isfinite(a_ext) and a_ext > 0 else np.nan),
"depth": abs(ext - edge) / edge,
"zg": zg, "zd": zd,
"width_pct": (zg - zd) / close[bo_idx],
"occ": occ, "zone_i": zone_i,
})
cursor = entry_idx + 1
out = pd.DataFrame(rows)
if out.empty:
return out
return (out.sort_values(["entry_idx", "occ", "zone_i"])
.drop_duplicates("entry_idx", keep="first")
.reset_index(drop=True))
+28 -2
View File
@@ -18,8 +18,16 @@ NAME="${NAME:-shadow}"
# 设 0 可退回 full,用来复量两模式的耗时差。
SHADOW_LEAN="${SHADOW_LEAN:-1}"
# 币池。默认三个流动性最好的做滑点测量;十币池是实际要交易的那批(TRX 剔除,
# ATR 门控几乎全刷掉)。币数直接决定排队:所有币同一秒收盘,2 核上 10 个币
# 需要约 640ms 墙钟才算完,最后一个币的信号会落在 800ms 哨兵线之外。
# ATR 门控几乎全刷掉)。
#
# 币数超过核数时排队会成为主项:所有币同一秒收盘。此时**加 worker 没用**
# ——CPU 密集的活,worker 超过核数不增吞吐,只会把等待从 queue_ms 挪到
# inner_ms。增量路径(SHADOW_INCR=1,默认开)已把清空压到 300~560ms。
#
# 再往下压的顺序见 HANDOFF §5.72(口径对齐后的实测):单币 inner 100ms ≈
# 信号链 30ms + 追加 2.81 根 37ms + 2 核争抢 33ms。争抢只有 1.49x,所以
# **加核收益有限**;最便宜的一刀是按币绑定 worker(现在 symbol 随机落
# worker,每份缓存都漏掉对方处理过的根,于是人人要追 2.81 根而非 1 根)。
SYMS="${SYMS:-BTC,ETH,SOL}"
IMAGE="${SHADOW_IMAGE:-hummingbot/hummingbot:latest}"
HOURS="${HOURS:-168}"
@@ -28,6 +36,10 @@ MAX_OFFSET_MS="${MAX_OFFSET_MS:-10}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
OUT="$REPO_ROOT/research/out"
# 信号总线的宿主机目录。**刻意放在仓库外**:这是交给实盘执行器(另一台机)的
# 交接点,而仓库会被 git checkout/clean 动。执行器那台用 ssh tail 拉这个文件,
# 所以它也不能在容器内部,必须挂出来
BUS_DIR="${BUS_DIR:-$HOME/chan-live/state}"
die() { printf '\033[31m错误:%s\033[0m\n' "$*" >&2; exit 1; }
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
@@ -37,6 +49,12 @@ say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
say "站点 $SHADOW_SITE"
mkdir -p "$BUS_DIR"
# 容器内是 root,写出来的总线文件宿主机上归 root。执行器那台用普通用户
# ssh 过来 tail,所以目录要可进入、文件要可读
chmod 755 "$BUS_DIR" 2>/dev/null || true
echo "信号总线:$BUS_DIR/signals_live.jsonl(容器内挂成 /bus"
say "校验时钟同步"
offset_ms=""
if command -v chronyc >/dev/null 2>&1; then
@@ -108,8 +126,16 @@ docker run -d --name "$NAME" -w /home/hummingbot \
-e PYTHONPATH=/home/hummingbot:/repo/research:/repo/research/live:/repo \
-e SHADOW_SITE="$SHADOW_SITE" \
-e SHADOW_LEAN="$SHADOW_LEAN" \
-e SHADOW_INCR="${SHADOW_INCR:-1}" \
-e TG_TOKEN="${TG_TOKEN:-}" \
-e TG_CHAT="${TG_CHAT:-}" \
-e TG_NOTIONAL="${TG_NOTIONAL:-500}" \
-e TG_LEVERAGE="${TG_LEVERAGE:-10}" \
-e TG_STALE_S="${TG_STALE_S:-90}" \
-e SIGNAL_BUS=/bus/signals_live.jsonl \
-v "$REPO_ROOT:/repo:ro" \
-v "$OUT:/out" \
-v "$BUS_DIR:/bus" \
--entrypoint /opt/conda/envs/hummingbot/bin/python \
"$IMAGE" /repo/research/live/shadow_hb.py \
--hours "$HOURS" --workers "$WORKERS" --syms "$SYMS" >/dev/null
+38
View File
@@ -0,0 +1,38 @@
# 复制成 tg.env 再填。tg.env 已在 .gitignore 里,不会被提交。
#
# 拿 tokenTelegram 里找 @BotFather → /newbot → 按提示起名
# 拿 chat id:给你的 bot 随便发一句,然后打开
# https://api.telegram.org/bot<TOKEN>/getUpdates
# 返回的 result[0].message.chat.id 就是
export TG_TOKEN=""
export TG_CHAT=""
# 每笔名义额(USDT)。杠杆**不改**手续费与滑点(都按名义额收),所以抬名义额
# 是有真实成本的;抬它的唯一理由是压掉步长取整:实测最差币的偏差
# 100U → 6.7%(SOL)、500U → 1.8%、1000U → 0.6%。
export TG_NOTIONAL="500"
# 杠杆只影响占用保证金,不影响名义敞口/手续费/滑点/盈亏绝对值。
# 名义 500 在 10x 下占 50 USDT 保证金;止损在 2 ATR ≈ 0.2%,而 10x 强平约需
# 逆向 10% = 100 个 ATR,差 50 倍,所以这里的杠杆几乎不引入强平风险。
# 交易所侧记得设**逐仓**,让每笔最大损失被保证金封住。
export TG_LEVERAGE="10"
# 距「参考价成立」超过这么多秒就标为已失效。参考价是次根开盘价,
# 过了就不是回测那个成交价了
export TG_STALE_S="90"
# ── 自动化实盘(live_exec.py)──────────────────────────────────
# 只读+交易权限,**不要开提币权限**
export BITGET_API_KEY=""
export BITGET_API_SECRET=""
export BITGET_PASSPHRASE=""
# 名义额与杠杆。理由同 TG_NOTIONAL:抬名义额是为了压步长取整,不是为了赚更多
export LIVE_NOTIONAL="500"
export LIVE_LEVERAGE="10"
# 硬约束。这三条封住的是「代价不随仓位缩小」的那几类故障:
# MAX_OPEN 失控下单(单笔小但笔数无界)
# MAX_DAY 同上,日维度
# MAX_DAY_LOSS 策略真的不行但没人盯着
export LIVE_MAX_OPEN="3"
export LIVE_MAX_DAY="15"
export LIVE_MAX_DAY_LOSS="20"
# 信号超过这么久就不做。参考成交价是次根开盘价,过期后跑的不是回测那个价
export LIVE_STALE_S="20"
+123
View File
@@ -0,0 +1,123 @@
"""把 `inner_ms` 拆成和本地一致的分档,用来定位两边测不一致的那部分。
起因本地量到的构成是 TF_DF ~80%信号链 ~20%服务器报的是 chan 构建
22ms信号链 86ms按机器差×1.36也解释不了四倍差距说明两边测的不是
同一件事或者有个环节只在服务器上贵
用同一批窗口跑比较分档而不是总数两边都跑一遍再对表
python research/live/probe_inner.py --syms BTC,ETH,SOL --repeat 5
分档口径 shadow_signal.compute 的调用顺序一致
rebuild payload DataFrame
ind_ltf/htf 两条腿各自的 add_indicatorsTF_DF 内部会做这里单独计时
chan_ltf/htf TF_DF 构建lean
zones build_htf_zones
ladder add_zone_ladder
bsp find_fast_bsp3
timeline htf_fx_timeline5m 分型时间线
attach attach_htf_agree + attach_zone_ladder
注意 `ind_*` `chan_*` 在真实路径里是合一的TF_DF 内部调 add_indicators
这里拆开只为定位总和会略大于实际 inner_ms
"""
from __future__ import annotations
import argparse
import os
import sys
import time
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "1")
HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[1]))
sys.path.insert(0, str(HERE.parents[2]))
sys.path.insert(0, str(HERE.parent))
LTF_BARS, HTF_BARS = 2001, 801
def med(fn, n: int):
ts = []
out = None
for _ in range(n):
t = time.perf_counter()
out = fn()
ts.append((time.perf_counter() - t) * 1000)
return float(np.median(ts)), out
def probe(sym: str, repeat: int) -> dict:
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
add_zone_ladder, attach_htf_agree, attach_zone_ladder,
build_htf_zones, find_fast_bsp3, htf_fx_timeline,
)
from lib.data import fetch_ohlcv
dl = fetch_ohlcv(f"{sym}/USDT:USDT", "1m", LTF_BARS * 3).tail(LTF_BARS).reset_index(drop=True)
dh = fetch_ohlcv(f"{sym}/USDT:USDT", "5m", HTF_BARS * 3).tail(HTF_BARS).reset_index(drop=True)
probe_tf = TF_DF(lean=True)
r = {"sym": sym}
r["ind_ltf"], _ = med(lambda: probe_tf.add_indicators(dl.copy()), repeat)
r["ind_htf"], _ = med(lambda: probe_tf.add_indicators(dh.copy()), repeat)
r["chan_ltf"], cl = med(lambda: TF_DF(dl, 1, "1m", lean=True), repeat)
r["chan_htf"], ch = med(lambda: TF_DF(dh, 1, "5m", lean=True), repeat)
cdf = cl.dataframe
r["zones"], z = med(lambda: build_htf_zones(cdf, "1m", chan=cl), repeat)
if z is None or z.empty:
r["n_zones"] = 0
return r
z0 = z.reset_index(drop=True)
r["ladder"], zl = med(lambda: add_zone_ladder(z0), repeat)
r["bsp"], sg = med(lambda: find_fast_bsp3(cdf, zl), repeat)
r["timeline"], tl = med(lambda: htf_fx_timeline(ch, ch.dataframe), repeat)
r["attach"], _ = med(
lambda: attach_zone_ladder(attach_htf_agree(sg, cdf, tl), zl), repeat)
r["n_zones"], r["n_sig"] = len(z0), len(sg)
# 增量口径:init 一次后追加,看稳态单根成本
c = TF_DF(lean=True)
c.init_stream(dl.iloc[:-60].reset_index(drop=True), 1, "1m")
ts = []
for k in range(len(dl) - 60, len(dl)):
t = time.perf_counter()
c.append_bar(dl.iloc[k])
ts.append((time.perf_counter() - t) * 1000)
r["append_bar"] = float(np.median(ts[20:]))
return r
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--syms", default="BTC,ETH,SOL")
ap.add_argument("--repeat", type=int, default=5)
args = ap.parse_args()
rows = [probe(s.strip(), args.repeat) for s in args.syms.split(",") if s.strip()]
d = pd.DataFrame(rows).set_index("sym")
parts = [c for c in ("ind_ltf", "ind_htf", "chan_ltf", "chan_htf", "zones",
"ladder", "bsp", "timeline", "attach") if c in d]
d["合计"] = d[parts].sum(axis=1)
pd.set_option("display.width", 220)
print("\n分档耗时(ms,中位)")
print(d[parts + ["合计", "append_bar"]].round(2).to_string())
print("\n占比(%")
print((d[parts].div(d["合计"], axis=0) * 100).round(1).to_string())
print("\n规模")
print(d[[c for c in ("n_zones", "n_sig") if c in d]].to_string())
print("\n注:ind_* 与 chan_* 在真实路径里合一(TF_DF 内部调 add_indicators),"
"拆开只为定位,合计会略大于实际 inner_ms。")
if __name__ == "__main__":
main()
+96
View File
@@ -0,0 +1,96 @@
"""100 USDT 的仓位在各币上能不能下出来——下单精度与最小量。
手工小额实盘的第一个坑不在策略在交易规则100 USDT 的仓位要拆成两半
3 ATR 减半 50 USDT8 ATR 目标 50 USDT任一半低于最小下单量就下不出去
或者被精度取整到与计划偏差很大的数量
取整偏差会直接扭曲收益结构 50 USDT 被取整到 40减半那一腿实际只出了
40%剩余 60% 暴露在 8 ATR 目标上回测的收益结构假设是 50/50
python research/live/probe_rules.py --notional 100
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from decimal import Decimal
from pathlib import Path
HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[1]))
sys.path.insert(0, str(HERE.parent))
SYMS = os.environ.get(
"SYMS", "BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC").split(",")
async def run(notional: float) -> None:
from hummingbot.connector.derivative.bitget_perpetual.bitget_perpetual_derivative import ( # noqa: E501
BitgetPerpetualDerivative,
)
pairs = [f"{s}-USDT" for s in SYMS]
conn = BitgetPerpetualDerivative(
bitget_perpetual_api_key="", bitget_perpetual_secret_key="",
bitget_perpetual_passphrase="", trading_pairs=pairs,
trading_required=False)
await conn.start_network()
for _ in range(60):
await asyncio.sleep(1)
if conn.trading_rules and all(p in conn.trading_rules for p in pairs):
break
print(f"仓位 {notional:.0f} USDT · 减半腿 {notional / 2:.0f} USDT\n")
print(f" {'':<6}{'现价':>11}{'最小量':>12}{'量步长':>12}"
f"{'最小名义':>10} 减半腿可行性")
bad = []
for s in SYMS:
p = f"{s}-USDT"
r = conn.trading_rules.get(p)
if r is None:
print(f" {s:<6}{'取不到规则':>11}")
continue
ob = conn.get_order_book(p)
px = float((ob.get_price(True) + ob.get_price(False)) / 2) if ob \
else float("nan")
min_amt = float(r.min_order_size)
step = float(r.min_base_amount_increment)
min_not = float(r.min_notional_size or 0)
half_base = (notional / 2) / px
# 按步长向下取整——交易所就是这么处理的,向上取会下不出去
q = Decimal(str(half_base)) // Decimal(str(step)) * Decimal(str(step))
got = float(q)
if got < min_amt or (min_not and got * px < min_not):
verdict = f"⛔ 下不出(需 ≥ {max(min_amt, min_not / px):.6f}"
bad.append(s)
else:
dev = abs(got * px - notional / 2) / (notional / 2) * 1e4
verdict = f"{got:.6f} 币,偏差 {dev:.0f}bp"
if dev > 100:
verdict += " ⚠ 取整偏差大"
bad.append(s)
print(f" {s:<6}{px:>11,.4f}{min_amt:>12.6f}{step:>12.6f}"
f"{min_not:>10.1f} {verdict}")
print()
if bad:
print(f"{notional:.0f} USDT 下这些币的减半腿有问题:{','.join(bad)}")
print(f" 要么提高仓位,要么这些币不做减半、单腿到 8 ATR 出场——但后者")
print(f" 改了回测的收益结构,不能直接套用原预算。")
else:
print(f" {notional:.0f} USDT 在全部 {len(SYMS)} 个币上都能拆成两半下出。")
await conn.stop_network()
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--notional", type=float, default=100.0)
a = ap.parse_args()
asyncio.run(run(a.notional))
if __name__ == "__main__":
main()
+103 -13
View File
@@ -60,6 +60,7 @@ import json
import math
import os
import socket
import sys
import time
from collections import deque
from concurrent.futures import ProcessPoolExecutor
@@ -71,9 +72,22 @@ import pandas as pd
from lib.shadow_budget import LAG_ALARM_MS, LAG_WINDOW, lag_healthy
# 总线模块住在生产子树 live/ 下。方向是刻意的:**生产不 import 研究侧**
# 研究侧反过来读生产持有的契约。见 live/live_exec.py 文件头
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "live"))
import signal_bus # noqa: E402
import tg_notify # noqa: E402
# 站点标识。跨地对比时两台机器的 CSV 要能合起来读,没有这一列就分不清哪行
# 来自哪台。默认取主机名,部署脚本会显式传 SHADOW_SITE(如 sg-hetzner
SITE = os.environ.get("SHADOW_SITE") or socket.gethostname()
# 判「加 worker 有没有用」必须知道核数:CPU 密集的活,worker 超过核数不增吞吐
CORES = os.cpu_count() or 1
# 判定要知道增量开没开,否则增量已生效时还会继续推荐「走增量」
INCR_ON = os.environ.get("SHADOW_INCR", "1") not in ("0", "", "false")
# 少于这么多根就不送去算。缠论要先有分型再有笔再有中枢,几十根出不来中枢,
# 送过去只会白占一个计算槽
MIN_BARS = 200
SYMS = ("BTC", "ETH", "SOL")
# 多存一根:deque 尾部是尚未收盘的当前根,剔除后正好剩 step39 定下的窗口
@@ -324,6 +338,11 @@ class Shadow:
# 排队 / 纯计算的滚动窗口,用来判断加核有没有用
self.q_hist: deque = deque(maxlen=90)
self.i_hist: deque = deque(maxlen=90)
# 每个收盘时刻「清空所有币」耗时。这才是决定信号何时可下单的量:
# 币同一秒收盘,币数超 worker 数时后面的币串行等待,而这笔代价不
# 出现在任何单根的 queue_ms 或 inner_ms 里
self.clear_hist: deque = deque(maxlen=60)
self._clear_cur: dict[int, float] = {}
# 成交监听:已挂上的币,以及必须持有的 forwarder 强引用
# (PubSub 只存弱引用,不持有的话监听会被 GC 静默摘掉)
self._hooked: set[str] = set()
@@ -353,7 +372,8 @@ class Shadow:
# compute_ms 含排队;queue_ms/inner_ms 把它拆开,用来判断加核有没有用
"compute_ms", "queue_ms", "inner_ms",
"n_bars", "n_hits",
"n_pass", "atr_bp", "lag_med_ms", "lag_ok"])
"n_pass", "atr_bp", "lag_med_ms", "lag_ok",
"stream_bars"])
# 无条件漂移:每根都记,用来和信号根上的条件漂移对照
self.f_drf, self.w_drf = _writer(d / "shadow_drift.csv", [
"site", "sym", "kline_ts", "delay_label", "delay_ms",
@@ -548,13 +568,20 @@ class Shadow:
# 末行是刚开始的那根,未收盘,必须剔除,否则等于用未来数据
df_l = df_l[df_l["timestamp"] < kline_ts]
df_h = df_h[df_h["timestamp"] < kline_ts]
# WS 重连的瞬间 feed 的 deque 可能是空的。放行的话 worker 会抛
# 「DataFrame for 1m is empty」,白占一个计算槽(币数超核数时这笔
# 代价会推迟后面所有币),而报错文本还会让人以为是缺历史数据
if len(df_l) < MIN_BARS or len(df_h) < MIN_BARS:
print(f" [{sym}] 窗口过短(1m {len(df_l)} / 5m {len(df_h)} 根),"
f"跳过本根。feed 大概在重连", flush=True)
return
baseline = self._new_bar_open(sym, kline_ts)
lag_med, lag_ok = self._probe_lag(sym, t_data - kline_ts)
t0 = time.perf_counter()
payload = (df_l[NUM_COLS].values.tolist(),
df_h[NUM_COLS].values.tolist(), baseline, time.time())
df_h[NUM_COLS].values.tolist(), baseline, time.time(), sym)
loop = asyncio.get_running_loop()
from shadow_signal import compute_packed
try:
@@ -570,6 +597,13 @@ class Shadow:
self.q_hist.append(res["queue_ms"])
if res.get("inner_ms") is not None:
self.i_hist.append(res["inner_ms"])
# 同一 kline_ts 上取各币最大值即该时刻的清空耗时;只保留最近几个
# 时刻,否则这个 dict 会随运行时长无界增长
cur = self._clear_cur
cur[kline_ts] = max(cur.get(kline_ts, 0.0), float(compute_ms))
if len(cur) > 3:
done = min(cur)
self.clear_hist.append(cur.pop(done))
hits = res.get("hits", [])
atr_pct = res.get("atr_pct")
@@ -586,7 +620,10 @@ class Shadow:
"queue_ms": res.get("queue_ms"), "inner_ms": res.get("inner_ms"),
"n_bars": res.get("n_bars", 0),
"n_hits": len(hits), "n_pass": n_pass, "atr_bp": atr_bp,
"lag_med_ms": lag_med, "lag_ok": int(lag_ok)})
"lag_med_ms": lag_med, "lag_ok": int(lag_ok),
# 增量流当前窗口。恒等于 2001 说明缺口判定在每根都
# 回退重建,增量静默失效——只从耗时上看不出是哪一环
"stream_bars": res.get("stream_bars")})
self.f_lat.flush()
if baseline is not None and np.isfinite(baseline):
@@ -616,6 +653,23 @@ class Shadow:
self._record_later(sym, kline_ts, h, t_data, t_signal,
baseline, atr_pct, lag_ok),
f"record {sym}")
# 手工执行的推送。只推过全部滤网的,且 lag 退化时不推——那与
# 「停开新仓」是同一条规则,不能只在自动化里执行
if h["pass_all"] and atr_pct:
if not lag_ok:
print(f" [TG] {sym} lag 退化,按停开新仓规则不推",
flush=True)
else:
# 总线先写、推送后发。写盘是同步的且已 fsync,实盘据此
# 下单;推送要走网络,不能让它的延迟挡在下单前面
signal_bus.emit(sym, kline_ts, h["direction"],
float(baseline), float(atr_pct),
t_data - kline_ts)
self._spawn(
tg_notify.push_signal(
sym, h["direction"], float(baseline),
float(atr_pct), kline_ts, t_data - kline_ts),
f"tg {sym}")
def _probe_lag(self, sym: str, lag_ms: int) -> tuple[float, bool]:
"""记一根的到达延迟,返回 (滚动中位数, 该币是否健康)。
@@ -779,16 +833,12 @@ class Shadow:
if self.q_hist and self.i_hist:
q, i = float(np.median(self.q_hist)), float(np.median(self.i_hist))
# 建议要看绝对量级:lean + 新引擎后纯计算约 128ms,此时再提
# 「改增量计算」是误导——尾部已由数据腿主导,压计算换不到东西
if q > i:
verdict = "排队为主 → 加 worker/加核直接见效"
elif i > 400:
verdict = "纯计算为主且偏高 → 加核帮不上,需改增量计算"
else:
verdict = "纯计算为主但量级已低 → 无需再优化"
print(f" [计算] 排队中位 {q:.0f}ms · 纯计算中位 {i:.0f}ms"
f" · {verdict}worker {self.workers} 个 / 币 {len(SYMS)} 个)",
flush=True)
clear = float(np.median(self.clear_hist)) if self.clear_hist \
else float("nan")
print(f" [计算] 每币排队 {q:.0f}ms · 纯计算 {i:.0f}ms · "
f"清空全部 {len(SYMS)}{clear:.0f}ms"
f"worker {self.workers} / 核 {CORES}", flush=True)
print(f"{self._compute_verdict(q, i, clear)}", flush=True)
# 五分钟一根都没进来,说明管道断了。不喊一声就只能靠人翻日志
if self.n_bars == self._hb_last_bars:
print(f" ⚠ [停滞] 距上次心跳未处理任何 K 线"
@@ -796,6 +846,46 @@ class Shadow:
flush=True)
self._hb_last_bars = self.n_bars
def _compute_verdict(self, q: float, i: float, clear: float) -> str:
"""给出唯一可行的出路,而不是「哪一项数字更大」。
旧版比逐根的 q i结构上错了两处
1. 判据错真正要紧的是**一个收盘时刻清空所有币要多久**clear
不是单币的 q i所有币同一秒收盘币数超过 worker 数时后面的
币必然串行等待而这笔代价不出现在任何单根的 q i
2. 出路错排队为主 加核只在还有空闲核时成立worker 已等于
核数时 worker 不会增加吞吐CPU 密集的活变不出来只会把
等待从 queue_ms 挪到 inner_ms十币实测正是如此inner 被争抢从
144ms 抬到 192ms反而超过 queue 135ms于是判定落到量级已低
无需优化而此时最后一个币已经落在 1376ms
所以币数超过核数时 worker 不增吞吐出路有两级先上增量把真实计算
压下来增量之后剩的是争抢放大实测 3.3 §5.72那一级只能加核或
减币继续改算法收益有限
"""
if not np.isfinite(clear):
return "样本不足,暂不判定"
if clear < 400:
return f"清空 {clear:.0f}ms,宽裕,无需优化"
if self.workers < CORES and q > i:
return (f"排队为主且还有 {CORES - self.workers} 个空闲核 → "
f"--workers 加到 {CORES}")
if len(SYMS) <= CORES:
return f"清空 {clear:.0f}ms 偏高,但币数未超核数,先查别的争抢"
# 币数超核数:加 worker 不增吞吐,只能压单币耗时。但要看增量开没开,
# 否则会在增量已生效时继续推荐「走增量」——上线后实测踩到过
if not INCR_ON:
return (f"币数 {len(SYMS)} > 核数 {CORES},加 worker 无用(CPU 密集)"
f"。压单币耗时 → 开 SHADOW_INCR=1 走增量(实测 3.56x")
# 增量已生效时(§5.72 口径对齐后):单币 100ms ≈ 信号链 30ms + 追加
# 2.81 根 37ms + 争抢 33ms。争抢只有 1.49x,加核收益有限;而追加那 37ms
# 里约 22ms 纯属浪费——symbol 随机落 worker,各缓存都漏掉对方处理的根
return (f"币数 {len(SYMS)} > 核数 {CORES},增量已生效,加 worker 无用"
f"worker 已等于核数)。单币 {i:.0f}ms 里争抢只占约 1.5x"
f"最便宜的一刀是按币绑定 worker(每次只追 1 根,省约三分之一),"
f"其次是 add_indicators 增量化")
async def run(self) -> None:
await self.start()
tasks = [asyncio.create_task(self.sample_books()),
+66 -4
View File
@@ -40,10 +40,64 @@ for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
LEAN = os.environ.get("SHADOW_LEAN", "1") not in ("0", "", "false")
INCR = os.environ.get("SHADOW_INCR", "1") not in ("0", "", "false")
# 增量流缓存。worker 进程被复用,所以这个 dict 跨根存活。
# 键是 (symbol, timeframe)——2 个 worker 轮流拿 10 个币,每个 worker 最终会
# 缓存全部 10 个币,共 20 条流。
_STREAMS: dict[tuple, tuple] = {}
# 两次重建之间允许窗口长多少根。
#
# init_stream/append_bar **没有 trim**dataframe 靠 pd.concat 无界增长。所以
# 增量必然让窗口每根 +1,只能周期性 init_stream 拉回。取 500 的两个理由:
# 1. append_bar 里 rebuild_bi_zs 要整表重扫笔,是 O(n)。窗口涨 25% 成本也涨
# 约 25%500/2001 正好把这个膨胀压在 25% 以内。
# 2. 重建约 51ms、追加约 14ms,摊到 500 根上重建只加 0.07ms/根。
# 前提「输出对窗口长度不敏感」由 verify_window_sens.py 验过(+200/+500/+1000
# 全部逐字段一致),否则这个方案等于静默换掉一批信号。
MAX_GROW = 500
def _chan_for(key: tuple, df, tf: str, lean: bool):
"""拿该窗口对应的 chan 对象,能增量就增量,否则重建。
三种情况必须回退到全量重建否则会拿一个状态不对的流去出信号
缓存没有 首次见到这个币
窗口已长过阈值 MAX_GROW
缓存末根不在新窗口 说明中间断了很多根或时间戳回退接不上
第三种是最要紧的2 worker 轮流拿 10 个币某个 worker 可能隔几根才再
看到同一个币那几根要补齐但若缺口大到超出窗口就没法补只能重建
不检查而直接 append 会把不连续的 K 线接在一起笔和中枢全错且不报错
"""
from chanlun import TF_DF
ts = df["timestamp"].to_numpy("int64")
st = _STREAMS.get(key)
if st is not None:
chan, last_ts, base_n = st
if len(chan.dataframe) <= base_n + MAX_GROW and last_ts >= ts[0] \
and last_ts <= ts[-1] and (ts == last_ts).any():
for _, row in df[df["timestamp"] > last_ts].iterrows():
chan.append_bar(row)
_STREAMS[key] = (chan, int(ts[-1]), base_n)
return chan
# 重建走**批量** init_TF_DF,不用 init_stream。init_stream 是逐行
# `dataframe.iloc[idx]`,正是引擎提速刚修掉的反模式:实测 2001 根要
# 238.5ms,而批量 lean 只要 74.3ms,慢 3.2 倍。
# append_bar 能接在批量构建的对象上——_ensure_stream_state 会补出
# _klc_feed_last_klu,其余列表 init_TF_DF 都建好了。
chan = TF_DF(df.copy(), 1, tf, lean=lean)
_STREAMS[key] = (chan, int(ts[-1]), len(df))
return chan
def compute(df_l, df_h, entry_px: float | None = None,
lean: bool | None = None) -> dict:
lean: bool | None = None, sym: str | None = None,
incr: bool | None = None) -> dict:
"""在 df_l 的最后一根上找信号。df_l/df_h 都只含已收盘 K 线。
entry_px 是次根开盘价回测 entry_delay=1 的成交价用作 atr_pct
@@ -66,6 +120,8 @@ def compute(df_l, df_h, entry_px: float | None = None,
import pandas as pd
lean = LEAN if lean is None else lean
# 没有 sym 就无法给流分键,只能走全量——对拍脚本会用这条路径当基准
incr = (INCR if incr is None else incr) and sym is not None
try:
from chanlun import TF_DF
@@ -75,7 +131,8 @@ def compute(df_l, df_h, entry_px: float | None = None,
from lib.nested_level import build_htf_zones
from lib.shadow_budget import ATR_GATE_BP
chan_l = TF_DF(df_l, 1, "1m", lean=lean)
chan_l = _chan_for((sym, "1m"), df_l, "1m", lean) if incr \
else TF_DF(df_l, 1, "1m", lean=lean)
cdf = chan_l.dataframe
last = len(cdf) - 1
base = {"last_idx": last, "n_bars": int(len(df_l)), "hits": [],
@@ -111,7 +168,8 @@ def compute(df_l, df_h, entry_px: float | None = None,
# 5m 同向。算不出时 h1_agree 记 0,该信号自然不会通过 pass_all
if df_h is not None and len(df_h) > 0:
chan_h = TF_DF(df_h, 1, "5m", lean=lean)
chan_h = _chan_for((sym, "5m"), df_h, "5m", lean) if incr \
else TF_DF(df_h, 1, "5m", lean=lean)
hdf = chan_h.dataframe
tl = htf_fx_timeline(
signals_to_frame(extract_fx_signals(chan_h, hdf)), hdf)
@@ -180,11 +238,15 @@ def compute_packed(payload: tuple) -> dict:
t_start = time.time()
l_rows, h_rows, entry_px, *rest = payload
t_submit = rest[0] if rest else None
sym = rest[1] if len(rest) > 1 else None
t0 = time.perf_counter()
df_l = _rebuild(l_rows)
df_h = _rebuild(h_rows) if h_rows else None
out = compute(df_l, df_h, entry_px)
out = compute(df_l, df_h, entry_px, sym=sym)
# 落盘这两个数才能在线看出增量是否在生效:走了重建的根 grown 会等于窗口
st = _STREAMS.get((sym, "1m"))
out["stream_bars"] = int(len(st[0].dataframe)) if st else None
out["inner_ms"] = int((time.perf_counter() - t0) * 1000)
# 同一台机器,父子进程时钟一致,可直接相减
out["queue_ms"] = int((t_start - t_submit) * 1000) \
+230
View File
@@ -0,0 +1,230 @@
"""把过全部滤网的信号推到 Telegram,供手工执行。
## 为什么要这个
自动执行链一行都还没写下单 / 持仓状态 / 跨重启持久化 / 对账 / 熔断
过全部滤网的信号只有约 5.3 /低到人手能接先手工跑一批就能在写
自动化**之前**拿到真实费率档真实成交价真实出场行为让执行链的每个假设
都有实测对照而不是写完再发现出场模型不对
## 时效是这条路最大的风险
回测的成交价是**信号根的次根开盘价**信号在收盘瞬间产生人看到推送解锁
手机下单几十秒就过去了成交价已经不是那个开盘价所以推送里必须带
- 参考开盘价回测口径的成交价
- 该币的滑点预算还能容忍多少偏离
- 距信号产生已过多久
并且**超过 TG_STALE_S 就直接标记为已失效**而不是让人自己判断宁可漏做
不要在偏离预算之外入场那等于在负期望上开仓
## 环境变量
TG_TOKEN BotFather 给的 token缺失则整个推送静默关闭
TG_CHAT chat id
TG_NOTIONAL 每笔名义额默认 200 USDT小额实盘
TG_STALE_S 超过多少秒算失效默认 90
"""
from __future__ import annotations
import os
import time
TOKEN = os.environ.get("TG_TOKEN", "")
CHAT = os.environ.get("TG_CHAT", "")
# 名义额。杠杆不改手续费与滑点(都按名义额收),所以抬名义额是有真实成本的;
# 抬它的理由只有一个:把步长取整压下去。实测最差币的偏差
# 100U → 6.7%(SOL)、500U → 1.8%、1000U → 0.6%。
NOTIONAL = float(os.environ.get("TG_NOTIONAL", "500"))
# 杠杆只影响占用保证金,不影响名义敞口、手续费、滑点、盈亏绝对值。
# 止损在 2 ATR ≈ 0.2%,而 10x 的强平约需逆向 10% = 100 个 ATR,差 50 倍,
# 所以这里的杠杆几乎不引入强平风险。逐仓,让每笔的最大损失被保证金封住。
LEVERAGE = float(os.environ.get("TG_LEVERAGE", "10"))
STALE_S = float(os.environ.get("TG_STALE_S", "90"))
ENABLED = bool(TOKEN and CHAT)
# 出场参数。必须与 step43_fill_aware_budget 的口径一致,否则推的价位和
# 预算所依据的收益结构不是一回事
SL_ATR, SCALE_ATR, RUNNER_ATR, MAXB = 2.0, 3.0, 8.0, 48
_sent: set = set()
_rules: dict = {}
CONTRACTS = ("https://api.bitget.com/api/v2/mix/market/contracts"
"?productType=usdt-futures")
async def load_rules() -> dict:
"""拉一次合约规则,缓存。拉不到就返回空——推送退化为不取整,不阻断。
要的是数量步长和价格 tick缺了它们推出去的价位可能被交易所拒单
价格不在 tick 或者数量被取整到与计划差很多
"""
if _rules:
return _rules
try:
import aiohttp
async with aiohttp.ClientSession() as s:
async with s.get(CONTRACTS,
timeout=aiohttp.ClientTimeout(total=15)) as r:
d = await r.json()
for c in d.get("data") or []:
sym = c["symbol"]
if not sym.endswith("USDT"):
continue
_rules[sym[:-4]] = {
"step": float(c["sizeMultiplier"]),
"min_qty": float(c["minTradeNum"]),
"min_usdt": float(c["minTradeUSDT"]),
# priceEndStep 是 tick 的整数倍数,pricePlace 是小数位
"tick": float(c["priceEndStep"]) * 10 ** -int(c["pricePlace"]),
}
print(f" [TG] 已载入 {len(_rules)} 个合约的下单规则", flush=True)
except Exception as e:
print(f" [TG] 拉合约规则失败 {type(e).__name__}: {e},推送不做取整",
flush=True)
return _rules
def quantize(notional: float, px: float, r: dict) -> tuple[float, float]:
"""算入场数量与减半腿,返回 (入场量, 减半量)。
入场量取到**步长的偶数倍**这样一半天然落在步长上不这么做的话
SOL 步长 0.1 10.7 USDT100 USDT 的仓位一半是 0.45 不可表示
只能取 0.4减半腿变成全仓的 43% 而不是 50%而回测的收益结构假设
50/50名义额因此会在目标值上下浮动SOL 85~107小额实盘无所谓
"""
step = r["step"]
if step <= 0:
return notional / px, notional / px / 2
tgt = notional / px
# 以 2×step 为格点取最近的一格,至少一格
grid = step * 2
n = max(1.0, round(tgt / grid))
qty = n * grid
return qty, qty / 2.0
def snap_px(px: float, tick: float) -> float:
"""把价位对齐到 tick,否则限价单会被拒。"""
if tick <= 0:
return px
return round(px / tick) * tick
def levels(entry: float, atr: float, direction: int) -> dict:
"""按 2/3/8 ATR 算出绝对价位。
direction=+1 做多-1 做空剩余半仓的止损**保持在 2ATR**不移到成本
这是回测参数RUNNER_STOP=2.0移了就不是同一个收益结构
"""
s = 1.0 if direction > 0 else -1.0
return {"entry": entry,
"stop": entry - s * SL_ATR * atr,
"scale": entry + s * SCALE_ATR * atr,
"runner": entry + s * RUNNER_ATR * atr}
def _fmt(px: float) -> str:
# 币价跨度从 DOGE 的 0.2 到 BTC 的 10 万,固定小数位会把小价币截成 0
if px >= 1000:
return f"{px:,.1f}"
if px >= 10:
return f"{px:,.3f}"
return f"{px:.6f}"
def build(sym: str, direction: int, entry: float, atr_pct: float,
kline_ts: int, lag_ms: float, budget_bp: float,
age_s: float, rule: dict | None = None) -> str:
atr = entry * atr_pct
lv = levels(entry, atr, direction)
side = "做多 LONG" if direction > 0 else "做空 SHORT"
stale = age_s > STALE_S
if rule:
qty, half = quantize(NOTIONAL, entry, rule)
tick = rule["tick"]
lv = {k: snap_px(v, tick) for k, v in lv.items()}
notional = qty * entry
qty_line = (f"入场 {qty:.6g} 币 ≈ {notional:,.1f} USDT"
f" · 减半腿 {half:.6g} 币(正好一半)")
else:
qty = NOTIONAL / entry
notional = NOTIONAL
qty_line = f"入场 {qty:.6g} 币 ≈ {NOTIONAL:,.0f} USDT(未取整)"
margin = notional / LEVERAGE if LEVERAGE > 0 else notional
# 止损距入场 2 ATR,换成保证金的百分比才是「这笔最多亏多少本金」
loss_pct = SL_ATR * atr_pct * LEVERAGE * 100
head = f"⛔ 已失效({age_s:.0f}s > {STALE_S:.0f}s)· 不要入场" if stale \
else f"{side} {sym}"
lines = [
head,
"",
f"参考成交价 {_fmt(lv['entry'])} ← 回测口径(次根开盘)",
qty_line,
f"{LEVERAGE:.0f}x 逐仓 → 占用保证金 {margin:,.1f} USDT"
f" · 触止损亏 {notional * SL_ATR * atr_pct:,.2f} USDT"
f"(保证金的 {loss_pct:.1f}%",
f"距参考价成立 {age_s:.1f}s(含数据延迟 {lag_ms:.0f}ms,不可压缩)",
"",
f"止损 {_fmt(lv['stop'])} 2 ATRstop-market,全仓)",
f"减半 {_fmt(lv['scale'])} 3 ATR,限价 maker",
f"目标 {_fmt(lv['runner'])} (8 ATR,限价 maker,剩余半仓)",
f"超时 {MAXB} 分钟后市价平(剩余半仓止损仍在 2 ATR,不移成本)",
"",
f"ATR {atr_pct * 1e4:.1f}bp · 滑点预算 {budget_bp:.1f}bp",
f"→ 实际成交偏离参考价超过 {budget_bp:.1f}bp 就不值得做",
]
if stale:
lines.append("")
lines.append("时效已过:成交价已不是回测那个价,宁可漏做。")
return "\n".join(lines)
async def send(text: str) -> None:
"""推一条。传输层复用生产侧的 `live/tg.py`,这里不再维护第二份。
方向与 `signal_bus` 一致**生产持有实现研究侧反过来 import**反过来
写成生产 import 研究侧就等于把研究侧的依赖树绑到实盘进程上
"""
from pathlib import Path
import sys
p = str(Path(__file__).resolve().parents[2] / "live")
if p not in sys.path:
sys.path.insert(0, p)
import tg
await tg.send(text)
async def push_signal(sym: str, direction: int, entry: float, atr_pct: float,
kline_ts: int, lag_ms: float) -> None:
"""去重后推一条信号。
去重键取 (, K线时刻, 方向)同一根被重复处理补根池重建后重放不该
推两次否则人会开两次仓
时效的起点是 `kline_ts` 而不是信号产生时刻参考成交价次根开盘就是
kline_ts 那一刻存在的从信号时刻起算会漏掉数据延迟加计算那 0.5~1.5s
而那段是无法压缩的固定成本必须计入
"""
if not ENABLED:
return
key = (sym, int(kline_ts), int(direction))
if key in _sent:
return
_sent.add(key)
if len(_sent) > 5000:
_sent.clear()
from lib.shadow_budget import budget_of
b = budget_of(sym)
if not (b == b): # nan:该币当前环境不可做(如 TRX)
print(f" [TG] {sym} 无预算(当前环境不可做),不推", flush=True)
return
age = time.time() - kline_ts / 1000.0
rule = (await load_rules()).get(sym.upper())
await send(build(sym, direction, entry, atr_pct, kline_ts, lag_ms, b, age,
rule))
+222
View File
@@ -0,0 +1,222 @@
"""逐根对拍「全量重算」与「增量追加」,并量提速。
## 为什么必须逐根对拍,不能引用 HANDOFF §5.5
§5.5 验的是 step46 那批用例 bsp_list 那条链且是追加 150~200 vs
全量重建的整体哈希影子路径不同
- find_fast_bsp3 + build_htf_zones + htf_fx_timeline + attach_htf_context
- 流式对象**跨根复用** worker 轮流拿多个币同一条流可能隔几根才被
再次追加状态污染只会让信号悄悄换一批不报错不崩
而且代码阅读已经暴露一处偏差`init_stream/append_bar` 从不调 `cal_trend`
它只在 `get_klc_list` 所以增量路径下 `klc.trend` 恒为 UNKNOWN
HANDOFF 笔的计算依赖 klc.trend若为真增量的笔就和全量不同
那句话所引的 bi.py:221 其实在 `cal_trend` 自己的循环里不是 `cal_bi_list`
的依赖**这条只能由对拍来定论**不能靠读代码
## 判据
逐字段相同排除 last_idx/n_bars随窗口长度必然变 verify_window_sens
与计时字段数量相同而标志不同一样算失败
模拟真实调用模式连续推进且每根都按全量增量各算一次增量那侧
复用同一条流
python research/live/verify_incr_parity.py --syms BTC,ETH,SOL --n 300
"""
from __future__ import annotations
import argparse
import os
import sys
import time
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "1")
HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[1]))
sys.path.insert(0, str(HERE.parents[2]))
sys.path.insert(0, str(HERE.parent))
BASE_L, BASE_H = 2001, 801
def run_one(sym: str, cache: Path, n: int, start_at: int | None) -> dict:
import shadow_signal as ss
from verify_lean_parity import SKIP, WINDOW_KEYS, canon, load, signal_bars
skip = SKIP + WINDOW_KEYS + ("stream_bars",)
l_all, h_all = load(sym, "1m", cache), load(sym, "5m", cache)
l_ts = l_all["timestamp"].to_numpy("int64")
h_ts = h_all["timestamp"].to_numpy("int64")
# 从最后一个信号根往前 n 根开始,保证这段里一定有信号分支被执行
if start_at is None:
try:
sb = signal_bars(sym, cache)
sb = sb[(sb > BASE_L + n) & (sb < len(l_all) - 1)]
start_at = int(sb[-1]) - n + 5 if len(sb) else BASE_L + 10
except Exception:
start_at = BASE_L + 10
ends = [e for e in range(start_at, start_at + n) if e < len(l_all) - 1]
if not ends:
raise RuntimeError("窗口不足")
ss._STREAMS.clear()
same = diff = 0
t_full = t_incr = 0.0
n_hits = 0
first = None
rebuilds = 0
prev_grown = 0
for e in ends:
hi = int(np.searchsorted(h_ts, l_ts[e], side="right"))
df_l = l_all.iloc[e - BASE_L + 1:e + 1]
df_h = h_all.iloc[max(0, hi - BASE_H):hi]
entry = float(l_all["open"].to_numpy(float)[e + 1])
t0 = time.perf_counter()
rf = ss.compute(df_l.copy(), df_h.copy(), entry, incr=False)
t_full += time.perf_counter() - t0
t0 = time.perf_counter()
ri = ss.compute(df_l.copy(), df_h.copy(), entry, sym=sym, incr=True)
t_incr += time.perf_counter() - t0
grown = len(ss._STREAMS[(sym, "1m")][0].dataframe)
if grown <= prev_grown:
rebuilds += 1
prev_grown = grown
n_hits += len(rf.get("hits") or [])
if canon(rf, skip) == canon(ri, skip):
same += 1
else:
diff += 1
if first is None:
first = (e, canon(rf, skip), canon(ri, skip))
k = len(ends)
print(f" {k} 根 · 一致 {same} · 不一致 {diff} · 命中 {n_hits} 个 · "
f"重建 {rebuilds} 次 · 末窗 {prev_grown}")
print(f" 单根 全量 {t_full / k * 1000:.1f}ms → "
f"增量 {t_incr / k * 1000:.1f}ms "
f"{t_full / max(t_incr, 1e-9):.2f}x")
if first:
e, a, b = first
print(f" ⚠ 首个分歧 idx={e}\n 全量: {a[:300]}\n 增量: {b[:300]}")
ss._STREAMS.clear()
del l_all, h_all
return {"sym": sym, "n": k, "same": same, "diff": diff, "hits": n_hits,
"full_ms": t_full / k * 1000, "incr_ms": t_incr / k * 1000}
def interleave(sym: str, cache: Path, n: int, nw: int) -> dict:
"""模拟多 worker 交错:nw 份独立缓存轮流接同一个币。
这是单进程对拍覆盖不到的路径`ProcessPoolExecutor` 不保证同一个币落到
同一个 worker所以每个 worker 只能隔 nw 根才再见到这个币一次要补 nw
补根走的是 `for row in df[ts > last_ts]` 那个循环逻辑上等价于连续
追加 nw 等价是推理没实测过
缓存是 worker 进程内的 dict键含 symbol所以不存在worker A 的状态被
worker B 读到拿到别的币的状态亲和性影响的是内存每个 worker
最终缓存全部币与补根次数不影响正确性本函数就是来证这一点的
"""
import shadow_signal as ss
from verify_lean_parity import SKIP, WINDOW_KEYS, canon, load, signal_bars
skip = SKIP + WINDOW_KEYS + ("stream_bars",)
l_all, h_all = load(sym, "1m", cache), load(sym, "5m", cache)
l_ts = l_all["timestamp"].to_numpy("int64")
h_ts = h_all["timestamp"].to_numpy("int64")
try:
sb = signal_bars(sym, cache)
sb = sb[(sb > BASE_L + n) & (sb < len(l_all) - 1)]
start = int(sb[-1]) - n + 5 if len(sb) else BASE_L + 10
except Exception:
start = BASE_L + 10
ends = [e for e in range(start, start + n) if e < len(l_all) - 1]
caches: list[dict] = [{} for _ in range(nw)]
same = diff = n_hits = 0
first = None
for j, e in enumerate(ends):
hi = int(np.searchsorted(h_ts, l_ts[e], side="right"))
df_l = l_all.iloc[e - BASE_L + 1:e + 1]
df_h = h_all.iloc[max(0, hi - BASE_H):hi]
entry = float(l_all["open"].to_numpy(float)[e + 1])
rf = ss.compute(df_l.copy(), df_h.copy(), entry, incr=False)
# 轮流换缓存 = 轮流换 worker
ss._STREAMS = caches[j % nw]
ri = ss.compute(df_l.copy(), df_h.copy(), entry, sym=sym, incr=True)
n_hits += len(rf.get("hits") or [])
if canon(rf, skip) == canon(ri, skip):
same += 1
else:
diff += 1
if first is None:
first = (e, canon(rf, skip), canon(ri, skip))
grown = [len(c[(sym, "1m")][0].dataframe) for c in caches
if (sym, "1m") in c]
print(f" {len(ends)} 根 · {nw} 份缓存轮流 · 一致 {same} · 不一致 {diff}"
f" · 命中 {n_hits} 个 · 各缓存末窗 {grown}")
if first:
e, a, b = first
print(f" ⚠ 首个分歧 idx={e}\n 全量: {a[:300]}\n 增量: {b[:300]}")
ss._STREAMS = {}
del l_all, h_all
return {"sym": sym, "n": len(ends), "same": same, "diff": diff,
"hits": n_hits, "full_ms": 0.0, "incr_ms": 0.0}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--interleave", type=int, default=0,
help="模拟这么多个 worker 轮流接同一个币(一次补多根)")
ap.add_argument("--syms", default="BTC,ETH,SOL")
ap.add_argument("--cache", default="research/live/cache")
ap.add_argument("--n", type=int, default=300)
ap.add_argument("--start", type=int, default=None)
a = ap.parse_args()
rows = []
for sym in a.syms.split(","):
print(f"\n{'=' * 70}\n{sym}")
try:
if a.interleave:
rows.append(interleave(sym, Path(a.cache), a.n, a.interleave))
else:
rows.append(run_one(sym, Path(a.cache), a.n, a.start))
except Exception as e:
print(f" 跳过:{e!r}")
if not rows:
return
d = pd.DataFrame(rows)
print(f"\n\n{'=' * 70}\n汇总\n")
print(f" 对拍 {int(d['n'].sum()):,} 根 · 不一致 {int(d['diff'].sum())} · "
f"命中 {int(d['hits'].sum())}")
if d["incr_ms"].sum() > 0:
print(f" 单根 全量 {d['full_ms'].mean():.1f}ms → "
f"增量 {d['incr_ms'].mean():.1f}ms "
f"{d['full_ms'].sum() / max(d['incr_ms'].sum(), 1e-9):.2f}x")
if int(d["diff"].sum()) == 0:
print("\n 逐字段一致,增量可以上线。")
else:
print("\n ⛔ 有分歧,不要上线。增量流的状态与全量重建不等价。")
if __name__ == "__main__":
main()
+7 -2
View File
@@ -50,7 +50,12 @@ def load(sym: str, tf: str, cache: Path) -> pd.DataFrame:
return pd.read_feather(c[0])
def canon(d: dict) -> str:
# 随窗口长度必然改变的记账字段。比「窗口长度会不会改信号」时要排除它们,
# 否则一定 0/12 不一致,而那是记账字段在变,不是信号在变
WINDOW_KEYS = ("last_idx", "n_bars")
def canon(d: dict, skip: tuple = SKIP) -> str:
"""把返回值规范化成可比较的字符串。
浮点直接比会被末位差异误判 lean 走的是同一段算术不该有任何差异
@@ -68,7 +73,7 @@ def canon(d: dict) -> str:
return norm(v.item())
return v
return json.dumps({k: norm(v) for k, v in sorted(d.items())
if k not in SKIP}, sort_keys=True, ensure_ascii=False)
if k not in skip}, sort_keys=True, ensure_ascii=False)
def signal_bars(sym: str, cache: Path) -> np.ndarray:
+115
View File
@@ -0,0 +1,115 @@
"""compute() 的输出对窗口长度是否不变——增量路径的前提。
## 为什么这是增量路径的前提
`init_stream/append_bar` 没有 trim`dataframe` `pd.concat` 无界增长所以
增量方案必然意味着**窗口会长大**每根 +1只能靠周期性 `init_stream` 重建
拉回于是在两次重建之间实际窗口是 [W, W+slack] 而不是恒定 W
这就把一个问题摆在前面如果 `compute()` 的输出随窗口长度变化增量路径等于
静默把信号换了一批不报错不崩只是测的不再是回测那批信号
step39 的结论是命中率在 2000 根饱和**饱和不等于不变**再加根数不再提高
命中率结果逐字段相同是两回事所以要单独验
判据是逐字段相同不是命中数相同数量相同而方向或滤网标志不同会让影子测
的是另一批信号
python research/live/verify_window_sens.py --syms BTC,ETH,SOL
"""
from __future__ import annotations
import argparse
import os
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "1")
HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[1]))
sys.path.insert(0, str(HERE.parents[2]))
sys.path.insert(0, str(HERE.parent))
BASE_L, BASE_H = 2001, 801
# 增量在两次重建之间会长这么多。取 500 是因为它对应约 8 小时,
# 重建摊薄后单根成本仍接近纯追加
GROW = (0, 200, 500, 1000)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--syms", default="BTC,ETH,SOL")
ap.add_argument("--cache", default="research/live/cache")
ap.add_argument("--n", type=int, default=40)
a = ap.parse_args()
from shadow_signal import compute
from verify_lean_parity import (SKIP, WINDOW_KEYS, canon, load,
signal_bars)
# last_idx/n_bars 必然随窗口长度变。不排除的话结果一定是「全不一致」,
# 而那说明的是记账字段在变,不是信号在变
skip = SKIP + WINDOW_KEYS
cache = Path(a.cache)
print("同一根上,只改窗口长度,比对 compute() 的全部返回字段")
print(f"基准窗口 1m×{BASE_L} + 5m×{BASE_H};增量会让它长大,故试 "
f"+{GROW[1:]}\n")
tot = {g: [0, 0] for g in GROW[1:]} # [相同, 不同]
for sym in a.syms.split(","):
l_all, h_all = load(sym, "1m", cache), load(sym, "5m", cache)
l_ts = l_all["timestamp"].to_numpy("int64")
h_ts = h_all["timestamp"].to_numpy("int64")
try:
sb = signal_bars(sym, cache)
except Exception as e:
print(f"{sym} 取信号根失败:{e!r}")
continue
need = BASE_L + max(GROW)
sb = sb[(sb > need) & (sb < len(l_all) - 1)][-a.n:]
if len(sb) == 0:
print(f"{sym} 可用信号根不足")
continue
res: dict[int, list[str]] = {g: [] for g in GROW}
for e in sb:
hi = int(np.searchsorted(h_ts, l_ts[e], side="right"))
entry = float(l_all["open"].to_numpy(float)[e + 1])
for g in GROW:
df_l = l_all.iloc[e - (BASE_L + g) + 1:e + 1]
df_h = h_all.iloc[max(0, hi - (BASE_H + g // 5)):hi]
res[g].append(canon(compute(df_l.copy(), df_h.copy(), entry),
skip=skip))
print(f"{sym} {len(sb)} 根信号窗口")
for g in GROW[1:]:
same = sum(1 for x, y in zip(res[0], res[g]) if x == y)
tot[g][0] += same
tot[g][1] += len(sb) - same
print(f" +{g:>4} 根 → 一致 {same}/{len(sb)}"
+ ("" if same == len(sb) else " ⚠ 有分歧"))
del l_all, h_all
print(f"\n{'=' * 66}\n汇总\n")
for g in GROW[1:]:
s, d = tot[g]
print(f" 窗口 +{g:>4} 根:一致 {s} · 不一致 {d}")
worst = max(GROW[1:], key=lambda g: tot[g][1])
if tot[worst][1] == 0:
print("\n 窗口长度不影响输出,增量路径的前提成立。")
print(" 可以按「长到 +N 根再 init_stream 重建」摊薄成本。")
else:
print("\n ⛔ 窗口长度会改变输出,增量路径会静默换掉一批信号。")
print(" 此时要么每根都重建(等于没有增量),要么先把窗口效应本身收口。")
if __name__ == "__main__":
main()
+29 -2
View File
@@ -75,6 +75,26 @@ def collect(sym: str, rows: int) -> pd.DataFrame | None:
idx = sig["entry_idx"].to_numpy().astype(int)
atr = cdf["atr"].to_numpy(float)[idx]
close = cdf["close"].to_numpy(float)[idx]
vol = cdf["volume"]
vr = vol / vol.rolling(60, min_periods=10).mean()
vr60 = vr.to_numpy(float)
# shift(1) 把信号根自己排除在外
vpre10 = vr.rolling(10, min_periods=5).mean().shift(1).to_numpy(float)
vpre30 = vr.rolling(30, min_periods=15).mean().shift(1).to_numpy(float)
c_all = cdf["close"].to_numpy(float)
a_all = cdf["atr"].to_numpy(float)
d_all = sig["direction"].to_numpy().astype(int)
def mom(k: int) -> np.ndarray:
"""顺方向动量(ATR 单位)。用全序列算好再取下标,避免逐笔切片。"""
prev = np.concatenate([np.full(k, np.nan), c_all[:-k]])
with np.errstate(invalid="ignore", divide="ignore"):
m = (c_all - prev) / a_all
out = np.full(len(idx), np.nan)
out[:] = m[idx] * d_all
return out
return pd.DataFrame({
"sym": sym,
"date": cdf["date"].to_numpy()[idx],
@@ -91,8 +111,15 @@ def collect(sym: str, rows: int) -> pd.DataFrame | None:
# 信号根的相对成交量。当根已收盘,开仓时可知,是合规的可交易信息。
# vr10 是引擎自带口径(前 10 根均量),vr60 换个基准做稳健性对照。
"vr10": cdf["volume_ratio"].to_numpy(float)[idx],
"vr60": (cdf["volume"] / cdf["volume"].rolling(60, min_periods=10).mean()
).to_numpy(float)[idx],
"vr60": vr60[idx],
# 开仓**之前**那段的量与走势。vpre 已 shift(1),不含信号根本身——
# 信号根的量单独由 vr60 承担,两者混在一起就分不清是哪一段在起作用。
"vpre10": vpre10[idx],
"vpre30": vpre30[idx],
# 顺方向动量,ATR 为单位。B4/S4 是回抽后转强,所以 mom10 多为负
# (入场前那几根逆着你走);负得多 = 回抽深。
"mom10": mom(10),
"mom60": mom(60),
})
except Exception as e:
print(f" {sym} 失败: {e!r}", flush=True)
+262
View File
@@ -0,0 +1,262 @@
"""Step 52:持仓中放量就平仓 —— 把成交量当出场信号。
用户提出既然 step50 证明入场根放量 = 给走完这一冲的人接盘那持仓过程中
出现放量根是不是也说明这一波被走完了该直接平掉
这和 step50 是同一个机制的延伸**方向未知**放量既可能是衰竭该走
也可能是突破续势的起点走了就砍在起涨点step50 只证明了入场时撞上放量
不好推不出持仓时撞上放量该跑入场是你在接别人的盘持仓时那根量
可能正是把你送上去的那批资金
出场腿是 taker收盘市价成本按 TIME 触发优先级同一根内止损盘中
> 目标盘中> 放量平仓收盘止损优先是保守侧
本步自己写了逐根模拟器不走 walk_exits**基线必须与 walk_exits 逐笔
相等才继续**否则后面所有对比都是在和一个错的基线比
"""
from __future__ import annotations
import argparse
import os
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(v, "1")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
pd.set_option("display.width", 340)
SL, SCALE_AT, RUNNER, MAXB = 2.0, 3.0, 8.0, 48
GATE_BP = 8.0
THRESHOLDS = (3.0, 5.0, 8.0)
OUT = HERE / "out" / "step52_volume_exit.feather"
IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai")
TP, SL_, TIME = 0, 1, 2
def simulate(cdf, sig, vr):
"""逐根前推。返回每笔在基线与各放量出场变体下的 (毛收益, 原因, 是否分批)。
runner 止损位与初始止损同为 2 ATRHANDOFF §3.5 定的 rstop=SL
所以全程止损线不动不需要分段处理
"""
high = cdf["high"].to_numpy(float)
low = cdf["low"].to_numpy(float)
open_ = cdf["open"].to_numpy(float)
close = cdf["close"].to_numpy(float)
atr = cdf["atr"].to_numpy(float)
n = len(cdf)
variants = ["base"]
for t in THRESHOLDS:
variants += [f"v{t:g}", f"v{t:g}w"]
rows = []
for s, d in zip(sig["entry_idx"].astype(int), sig["direction"].astype(int)):
e = s + 1
if e >= n - 1:
continue
a = atr[s]
if not np.isfinite(a) or a <= 0:
continue
entry = open_[e]
end = min(e + MAXB, n - 1)
row = {"sig_idx": s, "atr_pct": a / entry}
for var in variants:
thr = None if var == "base" else float(var[1:].rstrip("w"))
only_win = var.endswith("w")
scaled = False
g = r = None
xb = end
for j in range(e, end + 1):
adv = (high[j] - entry) / a if d == 1 else (entry - low[j]) / a
ret = (entry - low[j]) / a if d == 1 else (high[j] - entry) / a
# ① 止损(盘中)。同根内优先于目标,保守侧
if ret >= SL:
hit = -SL * a / entry
g = 0.5 * (SCALE_AT * a / entry) + 0.5 * hit if scaled else hit
r, xb = SL_, j
break
# ② 目标(盘中限价)
if not scaled and adv >= SCALE_AT:
# 同根内可能既到 3 ATR 又到 8 ATR,按先减仓后续跑处理
scaled = True
if adv >= RUNNER:
g = 0.5 * (SCALE_AT * a / entry) + 0.5 * (RUNNER * a / entry)
r, xb = TP, j
break
elif scaled and adv >= RUNNER:
g = 0.5 * (SCALE_AT * a / entry) + 0.5 * (RUNNER * a / entry)
r, xb = TP, j
break
# ③ 放量平仓(收盘市价)
if thr is not None and np.isfinite(vr[j]) and vr[j] >= thr:
px = d * (close[j] - entry) / entry
if not only_win or px > 0:
g = 0.5 * (SCALE_AT * a / entry) + 0.5 * px if scaled else px
r, xb = TIME, j
break
if g is None: # 超时:末根收盘市价
px = d * (close[end] - entry) / entry
g = 0.5 * (SCALE_AT * a / entry) + 0.5 * px if scaled else px
r, xb = TIME, end
row[f"{var}_g"], row[f"{var}_r"] = g, r
row[f"{var}_c"] = int(scaled)
row[f"{var}_b"] = xb - e + 1
rows.append(row)
return pd.DataFrame(rows)
def collect(sym: str, rows: int):
import warnings as _w
_w.filterwarnings("ignore")
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
add_zone_ladder, attach_htf_agree, attach_zone_ladder,
build_htf_zones, find_fast_bsp3, htf_fx_timeline,
)
from lib.data import fetch_ohlcv
from lib.exit_model import cfg_name, walk_exits
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", "1m", rows)
if df is None or len(df) < 50_000:
return None
chan = TF_DF(df, 1, "1m", lean=True)
cdf = chan.dataframe
zones = build_htf_zones(cdf, "1m", chan=chan)
if zones.empty:
return None
zl = add_zone_ladder(zones.reset_index(drop=True))
sig = find_fast_bsp3(cdf, zl)
if sig.empty:
return None
dh = fetch_ohlcv(f"{sym}/USDT:USDT", "5m", 10 ** 9)
ch = TF_DF(dh, 1, "5m", lean=True)
sig = attach_zone_ladder(
attach_htf_agree(sig, cdf, htf_fx_timeline(ch, ch.dataframe)), zl)
vr = (cdf["volume"] / cdf["volume"].rolling(60, min_periods=10).mean()
).to_numpy(float)
res = simulate(cdf, sig, vr)
# 对拍:基线必须与已验证的 walk_exits 逐笔相等
ref = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
runners=(RUNNER,), runner_stops=(SL,))
cfg = cfg_name(SL, RUNNER, MAXB, SL)
if len(ref) != len(res):
print(f" {sym} 对拍失败:笔数 {len(ref)} vs {len(res)}", flush=True)
return None
dg = np.abs(ref[f"{cfg}_g"].to_numpy() - res["base_g"].to_numpy())
dr = (ref[f"{cfg}_r"].to_numpy() != res["base_r"].to_numpy()).sum()
if dg.max() > 1e-12 or dr:
print(f" {sym} 对拍失败:毛收益最大差 {dg.max():.3e},原因分歧 {dr}",
flush=True)
return None
idx = sig["entry_idx"].to_numpy().astype(int)
keep = np.isin(idx, res["sig_idx"].to_numpy())
idx = idx[keep]
atr = cdf["atr"].to_numpy(float)[idx]
close = cdf["close"].to_numpy(float)[idx]
out = pd.DataFrame({
"sym": sym,
"date": cdf["date"].to_numpy()[idx],
"atr_bp": atr / close * 1e4,
"htf": sig["htf_agree"].to_numpy()[keep],
"lad": sig["ladder_ok"].to_numpy()[keep],
"vr60_entry": vr[idx],
})
for c in res.columns:
if c not in ("sig_idx",):
out[c] = res[c].to_numpy()
return out
except Exception as e:
print(f" {sym} 失败: {e!r}", flush=True)
return None
def stat(d: pd.DataFrame, var: str, label: str) -> dict:
from lib.exit_model import fee_of, taker_notional
g = d[f"{var}_g"].to_numpy()
r, c = d[f"{var}_r"].to_numpy(), d[f"{var}_c"].to_numpy()
tn = taker_notional(r, c)
net = g - fee_of(r, c)
risk = SL * d.atr_pct.to_numpy()
R = net / risk
w, o = net[net > 0].sum(), -net[net <= 0].sum()
return {"方案": label, "笔数": len(d),
"胜率": f"{(net > 0).mean()*100:.1f}%",
"毛R": round((g / risk).mean(), 3), "净均R": round(R.mean(), 3),
"R夏普": round(R.mean() / R.std(ddof=1), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
"均持仓": round(d[f"{var}_b"].mean(), 1),
"触发率": f"{(r == TIME).mean()*100:.0f}%"}
def report(d: pd.DataFrame, label: str) -> None:
rows = [stat(d, "base", "基线(不看量)")]
for t in THRESHOLDS:
rows.append(stat(d, f"v{t:g}", f"vr60≥{t:g} 就平"))
rows.append(stat(d, f"v{t:g}w", f"vr60≥{t:g} 且浮盈才平"))
print(f"\n--- {label}{len(d)} 笔)---")
print(pd.DataFrame(rows).to_string(index=False))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,BNB,ETH,SOL,LINK,LTC,AVAX,XRP,DOGE,ADA")
ap.add_argument("--rows", type=int, default=800_000)
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
d = pd.read_feather(OUT)
else:
syms = [s.strip() for s in args.symbols.split(",")]
print(f"[放量出场] {len(syms)}× {args.rows} 根 1m\n", flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, args.rows): s for s in syms}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
print(f" [{i}/{len(syms)}] {fut[f]} {0 if r is None else len(r)}"
f"{' ⚠对拍未过' if r is None else ' 对拍通过'}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
d.to_feather(OUT)
d["date"] = pd.to_datetime(d["date"])
d = d[(d.htf == 1.0) & d.lad & (d.atr_bp >= GATE_BP)].copy()
print(f"\n实盘口径 {len(d)}{d.date.min():%Y-%m-%d} ~ {d.date.max():%Y-%m-%d}")
print("\n" + "=" * 118)
print("########## 放量出场 vs 基线 ##########")
report(d[d.date < IS_START], "样本外")
report(d[d.date >= IS_START], "发现期")
report(d, "全样本")
if __name__ == "__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
"""Step 53:开仓**之前**那段的量与走势方向。
用户提出的第三个位置前两个已有定论
step50 信号根放量 那根你是买方付的是资金已推到的价
step52 持仓中放量 那是资金来接你的货
本步问入场之前那几根呢资金是不是已经在里面了
这跟 B4/S4 的结构直接相关它是回抽后转强所以入场前那几根**通常逆着你走**
mom10 多为负负得多 = 回抽深那么回抽时缩量回抽时放量是有
明确含义的区分前者是没人卖后者是真有人在卖
四个因子全部在信号根收盘时可知
vpre10 / vpre30 10 / 30 根的相对量均值 shift(1)不含信号根
mom10 / mom60 顺方向动量ATR 为单位
必须控 `vr60`信号根自身的量step50 已证明它是强负因子不控的话
前段量会通过相关性借它的力看着有效其实是同一件事
"""
from __future__ import annotations
import argparse
import os
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(v, "1")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
pd.set_option("display.width", 340)
SL = 2.0
GATE_BP = 8.0
OUT = HERE / "out" / "step53_pre_entry.feather"
IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai")
FACTORS = [("vpre10", "前10根量"), ("vpre30", "前30根量"),
("mom10", "前10根顺向动量"), ("mom60", "前60根顺向动量")]
def collect(sym: str, rows: int):
import warnings as _w
_w.filterwarnings("ignore")
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
from step48_signal_timing import collect as _c
return _c(sym, rows)
def stat(g: pd.DataFrame, lab: str, denom: int) -> dict:
if len(g) < 40:
return {"分组": lab, "笔数": len(g), "备注": "样本不足"}
w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum()
return {"分组": lab, "笔数": len(g), "占比": f"{len(g)/denom*100:.0f}%",
"胜率": f"{(g.net > 0).mean()*100:.1f}%",
"毛R": round(g.gR.mean(), 3), "净均R": round(g.R.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2)}
def quartiles(d: pd.DataFrame, col: str, name: str, label: str) -> None:
x = d[d[col].notna() & np.isfinite(d[col])]
if len(x) < 300:
print(f" {name}: 样本不足")
return
x = x.copy()
x["bin"] = pd.qcut(x[col], 4, labels=["Q1最低", "Q2", "Q3", "Q4最高"])
rows = [stat(g, str(b), len(x)) for b, g in x.groupby("bin", observed=True)]
t = pd.DataFrame(rows)
med = x.groupby("bin", observed=True)[col].median().round(2).to_dict()
t.insert(1, "中位", [med.get(b) for b in t["分组"]])
print(f"\n--- {label} / {name}{col}---")
print(t.to_string(index=False))
def control_vr60(d: pd.DataFrame, col: str, name: str) -> None:
"""在信号根量的高/低两半内部各切一次,看因子是否还独立成立。"""
x = d[d[col].notna() & np.isfinite(d[col]) & d.vr60.notna()].copy()
x["vr60半"] = np.where(x.vr60 >= x.vr60.median(), "信号根高量", "信号根低量")
rows = []
for half, g in x.groupby("vr60半"):
g = g.copy()
g["h"] = pd.qcut(g[col], 2, labels=["", ""])
lo, hi = g[g.h == ""], g[g.h == ""]
if min(len(lo), len(hi)) < 40:
continue
rows.append({"因子": name, "控制层": half, "笔数": len(g),
"低组毛R": round(lo.gR.mean(), 3), "高组毛R": round(hi.gR.mean(), 3),
"毛R差": round(hi.gR.mean() - lo.gR.mean(), 3),
"低组余量": round(lo.net.mean() / lo.tn.mean() * 1e4, 2),
"高组余量": round(hi.net.mean() / hi.tn.mean() * 1e4, 2)})
if rows:
print(pd.DataFrame(rows).to_string(index=False))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,BNB,ETH,SOL,LINK,LTC,AVAX,XRP,DOGE,ADA")
ap.add_argument("--rows", type=int, default=800_000)
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
d = pd.read_feather(OUT)
else:
syms = [s.strip() for s in args.symbols.split(",")]
print(f"[开仓前的量与方向] {len(syms)}× {args.rows} 根 1m\n", flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, args.rows): s for s in syms}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
print(f" [{i}/{len(syms)}] {fut[f]} {0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
d.to_feather(OUT)
from step50_volume import prep
d["date"] = pd.to_datetime(d["date"])
d = prep(d[(d.htf == 1.0) & d.lad & (d.atr_bp >= GATE_BP)].copy())
oos, ins = d[d.date < IS_START], d[d.date >= IS_START]
print(f"\n实盘口径 {len(d)} 笔 | 样本外 {len(oos)} 发现期 {len(ins)}")
print(f"回抽确认:mom10 中位 {d.mom10.median():+.2f} ATR"
f"为负的占 {(d.mom10 < 0).mean()*100:.0f}%")
print("\n" + "=" * 118)
print("########## 一、四个因子各自看(样本外 / 发现期)##########")
for col, name in FACTORS:
for lab, part in (("样本外", oos), ("发现期", ins)):
quartiles(part, col, name, lab)
print("\n" + "=" * 118)
print("########## 二、控信号根自身的量(vr60)后是否还成立 ##########")
for col, name in FACTORS:
control_vr60(d, col, name)
print("\n" + "=" * 118)
print("########## 三、缩量回抽 vs 放量回抽 ##########")
x = d[d.vpre10.notna() & np.isfinite(d.vpre10) & d.mom10.notna()].copy()
x["回抽"] = np.where(x.mom10 < 0, "回抽(逆向)", "顺向进场")
x["前段量"] = np.where(x.vpre10 >= x.vpre10.median(), "放量", "缩量")
rows = []
for a in ("回抽(逆向)", "顺向进场"):
for b in ("缩量", "放量"):
g = x[(x.回抽 == a) & (x.前段量 == b)]
rows.append(stat(g, f"{a} × {b}", len(x)))
print(pd.DataFrame(rows).to_string(index=False))
print("\n 与信号根量的相关性(防止是同一件事换个说法)")
for col, name in FACTORS:
v = x[[col, "vr60"]].dropna()
print(f" {name:<14} vs vr60 r = {v[col].corr(v.vr60):+.3f}")
if __name__ == "__main__":
main()
+431
View File
@@ -0,0 +1,431 @@
"""Step 54:开仓时刻落在哪个时段——亚盘 / 欧盘 / 美盘谁更赚。
用户问的是时段但这个问题有两个必须先堵的坑否则很容易得出一个假的结论
ATR 混淆亚盘波动天然低而低 ATR 的信号因固定成本吃亏是已知结论
HANDOFF §3.5余量 = 净收益 / taker名义额ATR 越小分母越小
所以"亚盘差"完全可能只是"亚盘 ATR 低"换个说法必须在 ATR 分层内部再看
mom60 混淆step53 刚定论 mom60 是强因子Q4 最差而美盘开盘那几个
小时最容易出现已经走完一大段的行情不控的话时段会借它的力
所以本步的顺序是先逐小时看不设边界边界是人定的最容易带出想要的结论
再聚合成时段最后在 ATR mom60 的分层内部复核
样本内外沿用 step53 的切法IS_START任何只在一边成立的都不认
"""
from __future__ import annotations
import argparse
import os
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(v, "1")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
pd.set_option("display.width", 340)
SL = 2.0
GATE_BP = 8.0
SRC = HERE / "out" / "step53_pre_entry.feather"
IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai")
# 时段用 UTC 定义。三段等分是加密市场的通行切法,且不重叠——重叠定义会让
# 同一笔进两个桶,比较就没有意义了。真实的开盘时刻(伦敦 08:00、纽约 13:30
# 落在段内而非段首,所以另有一张逐小时表兜底,防止边界把结论切出来
SESSIONS = [("亚盘", 0, 8), ("欧盘", 8, 16), ("美盘", 16, 24)]
# 稳健性对照:按真实开盘时刻切,并把欧美重叠那段单列
SESSIONS_ALT = [("亚盘", 0, 7), ("欧盘", 7, 13), ("欧美重叠", 13, 17),
("美盘", 17, 21), ("淡时段", 21, 24)]
def stat(g: pd.DataFrame, lab: str, denom: int) -> dict:
if len(g) < 40:
return {"分组": lab, "笔数": len(g), "备注": "样本不足"}
w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum()
return {"分组": lab, "笔数": len(g), "占比": f"{len(g)/denom*100:.0f}%",
"胜率": f"{(g.net > 0).mean()*100:.1f}%",
"毛R": round(g.gR.mean(), 3), "净均R": round(g.R.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2),
"中位ATRbp": round(g.atr_bp.median(), 1)}
def label_session(h: pd.Series, table) -> pd.Series:
out = pd.Series("?", index=h.index, dtype=object)
for name, a, b in table:
out[(h >= a) & (h < b)] = name
return out
def hourly(d: pd.DataFrame) -> None:
"""逐小时。先看这个再谈时段——时段边界是人定的,逐小时不是。"""
print("\n" + "=" * 100)
print("########## 一、逐小时(UTC),不设时段边界 ##########")
rows = []
for h, g in d.groupby("utc_h"):
oos, ins = g[g.date < IS_START], g[g.date >= IS_START]
rows.append({
"UTC时": h, "北京时": (h + 8) % 24, "笔数": len(g),
"毛R": round(g.gR.mean(), 3),
"净均R": round(g.R.mean(), 3),
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2),
"中位ATRbp": round(g.atr_bp.median(), 1),
"样本外毛R": round(oos.gR.mean(), 3) if len(oos) >= 30 else None,
"发现期毛R": round(ins.gR.mean(), 3) if len(ins) >= 30 else None,
})
t = pd.DataFrame(rows)
print(t.to_string(index=False))
print(f"\n每小时平均只有 {len(d)/24:.0f} 笔,单个小时的数是噪声,"
f"看形状不要看单点。")
def sessions(d: pd.DataFrame, table, title: str) -> None:
print("\n" + "=" * 100)
print(f"########## {title} ##########")
d = d.copy()
d["seg"] = label_session(d.utc_h, table)
order = [n for n, _, _ in table]
for lab, part in (("全样本", d), ("样本外", d[d.date < IS_START]),
("发现期", d[d.date >= IS_START])):
rows = [stat(part[part.seg == n], n, len(part)) for n in order]
print(f"\n--- {lab}{len(part)} 笔)---")
print(pd.DataFrame(rows).to_string(index=False))
def control(d: pd.DataFrame, col: str, name: str, table) -> None:
"""在混淆变量的高/低两半内部各看一次时段。
时段若只是 ATR mom60的代理分层后段间差异会塌掉
"""
x = d[d[col].notna() & np.isfinite(d[col])].copy()
x["seg"] = label_session(x.utc_h, table)
x[""] = np.where(x[col] >= x[col].median(), f"{name}", f"{name}")
order = [n for n, _, _ in table]
rows = []
for lay, g in x.groupby(""):
r = {"控制层": lay, "笔数": len(g)}
for n in order:
s = g[g.seg == n]
r[n] = round(s.gR.mean(), 3) if len(s) >= 40 else None
vals = [r[n] for n in order if r[n] is not None]
r["极差"] = round(max(vals) - min(vals), 3) if len(vals) > 1 else None
rows.append(r)
print(f"\n--- 控 {name}{col})后的段间毛R ---")
print(pd.DataFrame(rows).to_string(index=False))
def permutation(d: pd.DataFrame, table, n_iter: int = 20_000,
seed: int = 0) -> None:
"""段间极差有没有超出随机分组的水平。
24 个小时聚成 3 本来就会因为噪声产生一定的段间差异不做这一步就没法
区分"时段有效""任意切三份都能切出这么大的差"
"""
rng = np.random.default_rng(seed)
seg = label_session(d.utc_h, table).to_numpy()
g = d.gR.to_numpy()
order = [n for n, _, _ in table]
obs_means = np.array([g[seg == n].mean() for n in order])
obs = obs_means.max() - obs_means.min()
cnt = 0
for _ in range(n_iter):
p = rng.permutation(seg)
m = np.array([g[p == n].mean() for n in order])
if m.max() - m.min() >= obs:
cnt += 1
print(f"\n置换检验:实测段间毛R极差 {obs:.3f}"
f"随机打乱 {n_iter} 次里有 {cnt/n_iter*100:.2f}% 达到或超过它 "
f"→ p = {cnt/n_iter:.4f}")
NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
def weekday(d: pd.DataFrame) -> None:
print("\n" + "=" * 100)
print("########## 四、星期几(按 UTC##########")
rows = []
for k, g in d.groupby("utc_dow"):
oos, ins = g[g.date < IS_START], g[g.date >= IS_START]
r = stat(g, NAMES[k], len(d))
# 样本内外必须并排看。时段那一节正是靠这一列拆穿"美盘最好"的
r["样本外毛R"] = round(oos.gR.mean(), 3) if len(oos) >= 30 else None
r["发现期毛R"] = round(ins.gR.mean(), 3) if len(ins) >= 30 else None
rows.append(r)
print(pd.DataFrame(rows).to_string(index=False))
print("\n--- 工作日 vs 周末 ---")
for lab, part in (("全样本", d), ("样本外", d[d.date < IS_START]),
("发现期", d[d.date >= IS_START])):
we = part.utc_dow >= 5
t = pd.DataFrame([stat(part[~we], "工作日", len(part)),
stat(part[we], "周末", len(part))])
print(f"\n{lab}{len(part)} 笔)")
print(t.to_string(index=False))
def weekend_controls(d: pd.DataFrame) -> None:
"""周末效应是不是 ATR / mom60 / 币种 的代理。
周末 ATR 中位比工作日低 1bp而低 ATR 吃亏是已知的所以必须分层复核
"""
print("\n" + "=" * 100)
print("########## 五、周末效应的混淆检查 ##########")
x = d.copy()
x["周末"] = np.where(x.utc_dow >= 5, "周末", "工作日")
for col, name in (("atr_bp", "ATR"), ("mom60", "前60根动量"),
("vpre10", "前10根量")):
g = x[x[col].notna() & np.isfinite(x[col])].copy()
g[""] = np.where(g[col] >= g[col].median(), f"{name}", f"{name}")
rows = []
for lay, s in g.groupby(""):
wd, we = s[s.周末 == "工作日"], s[s.周末 == "周末"]
if min(len(wd), len(we)) < 40:
continue
rows.append({"控制层": lay, "工作日毛R": round(wd.gR.mean(), 3),
"周末毛R": round(we.gR.mean(), 3),
"": round(we.gR.mean() - wd.gR.mean(), 3),
"工作日余量": round(wd.net.mean()/wd.tn.mean()*1e4, 2),
"周末余量": round(we.net.mean()/we.tn.mean()*1e4, 2)})
print(f"\n--- 控 {name} ---")
print(pd.DataFrame(rows).to_string(index=False))
print("\n--- 逐币:周末差是普遍的还是少数币带的 ---")
rows = []
for sym, s in x.groupby("sym"):
wd, we = s[s.周末 == "工作日"], s[s.周末 == "周末"]
if min(len(wd), len(we)) < 25:
continue
rows.append({"": sym, "工作日笔数": len(wd), "周末笔数": len(we),
"工作日毛R": round(wd.gR.mean(), 3),
"周末毛R": round(we.gR.mean(), 3),
"": round(we.gR.mean() - wd.gR.mean(), 3)})
t = pd.DataFrame(rows).sort_values("")
print(t.to_string(index=False))
neg = (t[""] < 0).sum()
print(f"\n{neg}/{len(t)} 个币周末更差")
def overlap_with_mom60(d: pd.DataFrame) -> None:
"""周末的劣势是不是已经被 step53 的 `mom60≥7` 过滤吃掉了。
这是决定"要不要再加一条时间过滤"的关键两个过滤若砍的是同一批单子
叠加只会白丢笔数控制表已有暗示mom60 低的那层周末差只有 -0.069
高的那层 -0.169
"""
print("\n" + "=" * 100)
print("########## 六、周末效应与 mom60 过滤的重叠 ##########")
x = d.copy()
x["周末"] = np.where(x.utc_dow >= 5, "周末", "工作日")
x["周日"] = np.where(x.utc_dow == 6, "周日", "其余")
print("\n--- 高动量(mom60≥7,step53 要砍的那批)在各组里的占比 ---")
for col in ("周末", "周日"):
t = x.groupby(col).apply(
lambda g: pd.Series({
"笔数": len(g),
"mom60≥7占比": f"{(g.mom60 >= 7).mean()*100:.1f}%",
"中位mom60": round(g.mom60.median(), 2)}))
print(t.to_string())
print("\n--- 施加 mom60<7 之后,周末差还剩多少 ---")
rows = []
for lab, sub in (("过滤前(全部)", x),
("过滤后(mom60<7", x[x.mom60 < 7])):
for col in ("周末", "周日"):
a = sub[sub[col] == sub[col].unique()[0]]
hi = sub[sub[col].isin(["周末", "周日"])]
lo = sub[sub[col].isin(["工作日", "其余"])]
if min(len(hi), len(lo)) < 40:
continue
rows.append({
"口径": lab, "对比": f"{col} vs 其余",
"差组笔数": len(hi), "差组毛R": round(hi.gR.mean(), 3),
"对照毛R": round(lo.gR.mean(), 3),
"毛R差": round(hi.gR.mean() - lo.gR.mean(), 3),
"差组余量": round(hi.net.mean()/hi.tn.mean()*1e4, 2),
"对照余量": round(lo.net.mean()/lo.tn.mean()*1e4, 2)})
print(pd.DataFrame(rows).drop_duplicates().to_string(index=False))
def perm_binary(d: pd.DataFrame, mask: np.ndarray, lab: str,
n_iter: int = 20_000, seed: int = 0) -> None:
"""两组均值差的置换检验。"""
rng = np.random.default_rng(seed)
g = d.gR.to_numpy()
obs = g[mask].mean() - g[~mask].mean()
n = int(mask.sum())
cnt = 0
for _ in range(n_iter):
idx = rng.permutation(len(g))[:n]
m = np.zeros(len(g), bool)
m[idx] = True
if abs(g[m].mean() - g[~m].mean()) >= abs(obs):
cnt += 1
print(f"\n置换检验({lab}):实测毛R差 {obs:+.3f}"
f"随机分组 {n_iter} 次里 {cnt/n_iter*100:.2f}% 达到或超过 "
f"→ p = {cnt/n_iter:.4f}")
def decision(d: pd.DataFrame, cuts: dict) -> None:
"""若砍掉某些时段,在不同真实滑点下的总R。
沿用 step53 的决策表口径砍掉一批信号既省成本也丢收益哪边大取决于
真实滑点所以必须按滑点扫一遍不能只报一个数
"""
print("\n" + "=" * 100)
print("########## 七、把时段做成过滤的决策表(发现期总R)##########")
ins = d[d.date >= IS_START]
slips = [0, 5, 8, 10, 12, 15, 18, 20]
rows = []
for lab, mask in cuts.items():
m = mask(ins)
sub = ins[m]
r = {"方案": lab, "保留": f"{m.mean()*100:.0f}%"}
for s in slips:
# 滑点只打在 taker 腿上,口径与 lib.exit_model 一致
net = sub.net - sub.tn * s / 1e4
r[f"{s}bp"] = round((net / (SL * sub.atr_pct)).sum(), 0)
rows.append(r)
print(pd.DataFrame(rows).to_string(index=False))
SRC41 = HERE / "out" / "step41_exit_tp.feather"
# step41 的分批出场配置名,与实盘完全一致:SL 2 / 减半 3(scale_at 默认)/
# runner 8 / runner 止损 2 / 48 根。见 lib.exit_model.cfg_name
CFG41 = "s2_so8_k2_m48"
def long_history() -> None:
"""用 5m/15m/30m 的 7 年数据复核时段与星期几。
为什么要这一步1m 只能追到 18.5 个月3941 而时段结论是"没效果"
**没效果最怕的就是样本不够**step41 那份跨 2019-09 2026-085m 单周期
就有 5434 且过滤口径相同`h1_agree==1 & push` 就是深色出场配置
也能对上实盘那套若长样本上仍然测不出时段效应"无效"才站得住
口径差一处这里没加 ATR8bp 门控§3.5 实测该门控在这些周期上几乎
不触发30m 0%15m 0.16%5m 2.6%所以影响可忽略但要记着
"""
from lib.exit_model import fee_of, taker_notional
d = pd.read_feather(SRC41)
d["date"] = pd.to_datetime(d["date"])
g, r, c = (d[f"{CFG41}_{k}"].to_numpy() for k in ("g", "r", "c"))
d["net"] = g - fee_of(r, c)
d["gR"] = g / (SL * d.atr_pct.values)
d["R"] = d.net / (SL * d.atr_pct.values)
d["tn"] = taker_notional(r, c)
d["atr_bp"] = d.atr_pct * 1e4
utc = d.date.dt.tz_convert("UTC")
d["utc_h"], d["utc_dow"] = utc.dt.hour, utc.dt.dayofweek
print("\n" + "#" * 100)
print("########## 长样本复核:5m/15m/30m × 7 年 ##########")
print(f"{len(d)} 笔 · {d.symbol.nunique()} 币 · "
f"{d.date.min():%Y-%m}{d.date.max():%Y-%m}"
f"{(d.date.max()-d.date.min()).days} 天)")
print(f"出场配置 {CFG41}= 实盘的 2/3/8/2/48)· 深色已过滤 · 无 ATR 门控")
for tf in ("5m", "15m", "30m"):
x = d[d.ltf == tf].copy()
if len(x) < 300:
continue
x["seg"] = label_session(x.utc_h, SESSIONS)
print(f"\n--- {tf}{len(x)} 笔)时段 ---")
rows = []
for n, _, _ in SESSIONS:
s = x[x.seg == n]
ins, oos = s[s.group == "样本内"], s[s.group == "样本外"]
row = stat(s, n, len(x))
row["样本内毛R"] = round(ins.gR.mean(), 3) if len(ins) >= 30 else None
row["样本外毛R"] = round(oos.gR.mean(), 3) if len(oos) >= 30 else None
rows.append(row)
print(pd.DataFrame(rows).to_string(index=False))
permutation(x, SESSIONS, n_iter=10_000)
we = (x.utc_dow >= 5).to_numpy()
print(f"\n--- {tf} 工作日 vs 周末 ---")
print(pd.DataFrame([stat(x[~we], "工作日", len(x)),
stat(x[we], "周末", len(x))]).to_string(index=False))
perm_binary(x, we, f"{tf} 周末 vs 工作日", n_iter=10_000)
print("\n--- 三周期合并(每笔等权)---")
d["seg"] = label_session(d.utc_h, SESSIONS)
print(pd.DataFrame([stat(d[d.seg == n], n, len(d))
for n, _, _ in SESSIONS]).to_string(index=False))
permutation(d, SESSIONS, n_iter=10_000)
we = (d.utc_dow >= 5).to_numpy()
print(pd.DataFrame([stat(d[~we], "工作日", len(d)),
stat(d[we], "周末", len(d))]).to_string(index=False))
perm_binary(d, we, "合并 周末 vs 工作日", n_iter=10_000)
perm_binary(d, (d.utc_dow == 6).to_numpy(), "合并 周日 vs 其余",
n_iter=10_000)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--src", default=str(SRC))
ap.add_argument("--long", action="store_true",
help="只跑 5m/15m/30m 的 7 年长样本复核")
args = ap.parse_args()
if args.long:
long_history()
return
from step50_volume import prep
d = pd.read_feather(args.src)
d["date"] = pd.to_datetime(d["date"])
d = prep(d[(d.htf == 1.0) & d.lad & (d.atr_bp >= GATE_BP)].copy())
utc = d.date.dt.tz_convert("UTC")
d["utc_h"] = utc.dt.hour
d["utc_dow"] = utc.dt.dayofweek
span = (d.date.max() - d.date.min()).days
print(f"实盘口径 {len(d)} 笔 · {d.sym.nunique()} 币 · 跨 {span}"
f"({d.date.min():%Y-%m-%d}{d.date.max():%Y-%m-%d})")
print(f"样本外 {(d.date < IS_START).sum()} · "
f"发现期 {(d.date >= IS_START).sum()}")
print("⚠️ 持仓最长 48 分钟,跨段的笔按**入场时刻**归属——那是唯一可操作的口径")
hourly(d)
sessions(d, SESSIONS, "二、三段等分(UTC 0/8/16")
permutation(d, SESSIONS)
sessions(d, SESSIONS_ALT, "三、稳健性对照:按真实开盘时刻切五段")
print("\n" + "=" * 100)
print("########## 混淆检查:时段是不是 ATR / mom60 的代理 ##########")
for col, name in (("atr_bp", "ATR"), ("mom60", "前60根动量"),
("vpre10", "前10根量")):
control(d, col, name, SESSIONS)
weekday(d)
perm_binary(d, (d.utc_dow >= 5).to_numpy(), "周末 vs 工作日")
perm_binary(d, (d.utc_dow == 6).to_numpy(), "周日 vs 其余")
weekend_controls(d)
overlap_with_mom60(d)
decision(d, {
"等权(现状)": lambda x: np.ones(len(x), bool),
"砍周日": lambda x: (x.utc_dow != 6).to_numpy(),
"砍周末": lambda x: (x.utc_dow < 5).to_numpy(),
"砍欧盘": lambda x: ((x.utc_h < 8) | (x.utc_h >= 16)).to_numpy(),
"砍 mom60≥7step53 基准)": lambda x: (x.mom60 < 7).to_numpy(),
"砍 mom60≥7 + 周日": lambda x: ((x.mom60 < 7) &
(x.utc_dow != 6)).to_numpy(),
})
if __name__ == "__main__":
main()
+348
View File
@@ -0,0 +1,348 @@
"""Step 55:一/二类买卖点的可行性探针——先量滞后,不急着跑收益。
用户提出把 B4 那套研究思路搬到一二类上搬之前必须先过一道闸理由写在
`Chan_BSP_TYPE` 的枚举注释里**B4 存在的全部意义就是"几何位置同 B3/S3
但不等笔确认"**。也就是说这个项目早就付过"等笔确认"的学费。
而一类买卖点是最滞后的一种构造它要中枢 `is_sure`要离开笔 `is_sure`
还要背驰判定`check_bi_div` `macd_hist`二类更靠后要在一类之后再走
两笔所以真正的问题不是"一二类赚不赚钱"而是
你能在什么时候知道它那时候价格还在不在
`ChanBSP` 给了两个时刻差值就是答案
klc.end_time = leave_bi.end_klc 的收盘时刻**极值所在**理想入场点
sure_time = 笔被确认的时刻**你最早能动手的时刻**
本步只回答三件事跑得快结论硬
1. 一二类各有多少笔对比 B4 5.3 / / 10
2. 滞后多少根
3. 这段等待里价格跑掉多少个 ATR 这是"入场价漂移"直接从余量里扣
不在本步做收益回测滞后若不可接受收益怎么算都是假的`walk_exits`
用的是信号根的次根开盘价而那个价在一二类上根本拿不到
"""
from __future__ import annotations
import argparse
import os
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(v, "1")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
pd.set_option("display.width", 340)
OUT = HERE / "out" / "step55_bsp12.feather"
# B4 的对照基线,来自 §3.31 / step4810 币 1m 实盘口径
B4_PER_DAY_10SYM = 5.3
# 出场结构与实盘完全一致,见 live/exit_params.py
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
def collect(sym: str, rows: int, tf: str = "1m") -> pd.DataFrame | None:
import warnings as _w
_w.filterwarnings("ignore")
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
from chanlun import TF_DF
from chanlun.core.ChanEnum import Chan_BSP_TYPE
from lib.data import fetch_ohlcv
from lib.exit_model import cfg_name, walk_exits
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
if df is None or len(df) < 5_000:
return None
# 必须走 fullcheck_bi_div 读 macd_hist,那只在 full 下算。
# 而 TF_DF 自己**不填** bsp_list——web 的 analyze.py 是显式调
# find_all_bsp 的,这里照抄那条链,口径才对得上
chan = TF_DF(df, 1, tf, lean=False)
cdf = chan.dataframe
bi_zs = chan.cal_bi_zs_list_pure(chan.bi_list)
if not bi_zs:
return None
bsp = chan.find_all_bsp(chan.bi_list, bi_zs)
if not bsp:
return None
# 索引映射有两个坑,都会静默给出错的下标:
# ① cdf.date 带时区(Asia/Shanghai),而 KLC 上的时间是**字符串**且
# 不带时区。它们本就是同一个时钟的墙上时间,所以去 tz 而不是硬贴。
# ② cdf.date 的单位是 datetime64[ms]`astype("int64")` 给的是毫秒,
# 而 `Timestamp.value` 是纳秒,差 1e6 倍——手工转整数会让
# searchsorted 全部落到末尾,且不报错。交给 DatetimeIndex 自己比。
dser = pd.to_datetime(cdf["date"])
if dser.dt.tz is not None:
dser = dser.dt.tz_localize(None)
didx = pd.DatetimeIndex(dser)
def to_i(ts) -> int:
t = pd.Timestamp(ts)
if t.tz is not None:
t = t.tz_localize(None)
return int(didx.searchsorted(t))
op = cdf["open"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
atr = cdf["atr"].to_numpy(float)
n = len(cdf)
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1),
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
rec = []
for b in bsp:
tag = want.get(b.type)
if tag is None or b.sure_time is None:
continue
name, d = tag
# 极值根:笔末 KLC 的收盘时刻。KLC 是合并后的,取它覆盖的最后一根
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
if not (0 <= i_ext < n and 0 <= i_sure < n):
continue
a = atr[i_ext]
if not np.isfinite(a) or a <= 0:
continue
# 理想价:极值根收盘。可执行价:确认根的**次根开盘**——与
# walk_exits / 实盘的口径一致(信号根收盘后才下单)
px_ideal = cl[i_ext]
j = min(i_sure + 1, n - 1)
px_real = op[j]
rec.append({
"sym": sym, "tf": tf, "type": name, "dir": d,
"date_ext": dser.iloc[i_ext], "date_sure": dser.iloc[i_sure],
"i_ext": i_ext, "i_sure": i_sure,
"lag_bars": i_sure - i_ext,
"atr_bp": a / px_ideal * 1e4,
# 结构位:笔末 KLC 的实际极值,不是收盘。止损要挂在它外面
# 才谈得上「守住结构」,用收盘价会把插针那一段漏掉
"ext_px": float(b.klc.low if d == 1 else b.klc.high),
"entry_px": px_real,
# 等待期间价格顺着信号方向跑掉了多少(正 = 你追高/追空,吃亏)
"drift_atr": (px_real - px_ideal) * d / a,
"drift_bp": (px_real - px_ideal) * d / px_ideal * 1e4,
})
if not rec:
return None
r = pd.DataFrame(rec)
# 入场口径与实盘一致:确认根收盘后下单,walk_exits 用 entry_idx+1 的
# 开盘价,ATR 取 entry_idx 那根。所以 entry_idx = i_sure**不是**
# i_ext——用极值根等于假设你能买在笔的低点,那是未来函数
r = r[(r.i_sure < len(cdf) - 2) & np.isfinite(atr[r.i_sure.values])
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
if r.empty:
return None
sig = pd.DataFrame({"entry_idx": r.i_sure.values,
"direction": r.dir.values})
res = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
runners=(RUNNER,), runner_stops=(RSTOP,))
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
if len(res) != len(r):
print(f" {sym} {tf} 长度不齐 {len(res)} vs {len(r)},跳过",
flush=True)
return None
for k in ("g", "r", "c", "b"):
r[k] = res[f"{cfg}_{k}"].to_numpy()
# 反向对照:胜率远低于随机说明信号带信息但可能指反了。出场是路径依赖的,
# 不能把 g 取负号了事,必须拿相反方向重跑一遍
flip = pd.DataFrame({"entry_idx": r.i_sure.values,
"direction": -r.dir.values})
rf = walk_exits(cdf, flip, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
runners=(RUNNER,), runner_stops=(RSTOP,))
if len(rf) == len(r):
for k in ("g", "r", "c", "b"):
r["f_" + k] = rf[f"{cfg}_{k}"].to_numpy()
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
# 原始 ATR,供结构止损换算用。atr_pct 除过价格,换不回来
r["atr_at_entry"] = atr[r.i_sure.values]
return r
except Exception as e:
print(f" {sym} 失败: {e!r}", flush=True)
return None
def report(d: pd.DataFrame, span_days: float, n_sym: int) -> None:
print("\n" + "=" * 100)
print(f"########## 一、笔数:够不够做 ##########")
t = d.groupby("type").agg(笔数=("lag_bars", "size"))
t[f"{n_sym}币每天"] = (t["笔数"] / span_days).round(2)
# 必须按币归一再比。B4 那个 5.3 是 10 币的合计,直接拿 5 币的数去除
# 等于凭空把速率打对折
t["每币每天"] = (t["笔数"] / span_days / n_sym).round(3)
t["对B4倍数"] = (t["笔数"] / span_days / n_sym /
(B4_PER_DAY_10SYM / 10)).round(2)
print(t.to_string())
print(f"\n对照基线:B4 在 1m 上 10 币 {B4_PER_DAY_10SYM} 笔/天 = "
f"**{B4_PER_DAY_10SYM/10:.3f} 笔/币/天**。"
f"本表 {n_sym} 币 / 跨 {span_days:.0f} 天。")
print("\n" + "=" * 100)
print("########## 二、滞后:从极值到可动手,差多少根 ##########")
rows = []
for name, g in d.groupby("type"):
q = g.lag_bars.quantile([0.25, 0.5, 0.75, 0.9]).round(1)
rows.append({"类型": name, "笔数": len(g),
"滞后中位": q[0.5], "P25": q[0.25], "P75": q[0.75],
"P90": q[0.9], "均值": round(g.lag_bars.mean(), 1),
"最大": int(g.lag_bars.max())})
print(pd.DataFrame(rows).to_string(index=False))
print("\n1m 上 1 根 = 1 分钟。B4 的滞后是 0 根——它在信号根收盘即可下单。")
print("\n" + "=" * 100)
print("########## 三、等待的代价:价格跑掉了多少 ##########")
print("drift 为正 = 等待期间价格顺着信号方向走了,你只能追;这一段直接从"
"余量里扣")
print("⚠️ 「追不上占比」必然是 100%,那是定义决定的不是实测发现:笔之所以"
"在那里结束,\n 正是因为价格从那个极值反向走了——`bi.end_klc` 是极值"
"点,之后必然朝信号方向偏离。\n **有信息量的是幅度,不是符号。**")
rows = []
for name, g in d.groupby("type"):
q = g.drift_atr.quantile([0.25, 0.5, 0.75]).round(2)
rows.append({
"类型": name, "笔数": len(g),
"漂移中位(ATR)": q[0.5], "P25": q[0.25], "P75": q[0.75],
"漂移均值(ATR)": round(g.drift_atr.mean(), 2),
"漂移均值(bp)": round(g.drift_bp.mean(), 1),
"中位ATR(bp)": round(g.atr_bp.median(), 1),
"追不上占比": f"{(g.drift_atr > 0).mean()*100:.0f}%"})
print(pd.DataFrame(rows).to_string(index=False))
print("\n" + "=" * 100)
print("########## 四、和止损宽度比:漂移吃掉多少风险预算 ##########")
print("实盘止损是 2 ATR。若漂移中位已经是 1 ATR,等于你的止损只剩一半,"
"而目标位还在原处——盈亏比被腰斩。")
rows = []
for name, g in d.groupby("type"):
rows.append({
"类型": name,
"漂移/止损(2ATR)": f"{g.drift_atr.median()/2*100:.0f}%",
"漂移超过 1ATR 占比": f"{(g.drift_atr > 1).mean()*100:.0f}%",
"漂移超过 2ATR(已穿止损)": f"{(g.drift_atr > 2).mean()*100:.0f}%"})
print(pd.DataFrame(rows).to_string(index=False))
def profit(d: pd.DataFrame) -> None:
"""各类买卖点按实盘出场结构的实际表现。
B3 是校验锚HANDOFF §4 已记引擎 `find_all_bsp` B3/S3 是系统性亏损
PF 0.66胜率 27.4%t 18.76若这里 B3 跑出个漂亮数字说明口径接错了
先别信 B1/B2 的结果
"""
from lib.exit_model import fee_of, taker_notional
if "g" not in d.columns:
print("\n(本次数据无回测列,跳过收益段;删掉 out/step55_bsp12.feather "
"重跑可得)")
return
x = d.dropna(subset=["g"]).copy()
x["net"] = x.g.values - fee_of(x.r.values, x.c.values)
x["gR"] = x.g.values / (SL * x.atr_pct.values)
x["R"] = x.net.values / (SL * x.atr_pct.values)
x["tn"] = taker_notional(x.r.values, x.c.values)
print("\n" + "=" * 100)
print("########## 五、按实盘出场结构(2/3/8/2/48)的实际表现 ##########")
rows = []
for name, g in x.groupby("type"):
if len(g) < 40:
continue
w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum()
rows.append({
"类型": name, "笔数": len(g),
"胜率": f"{(g.net > 0).mean()*100:.1f}%",
"毛R": round(g.gR.mean(), 3), "净均R": round(g.R.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"R夏普": round(g.R.mean() / g.R.std(ddof=1), 3),
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2),
"中位持仓": int(g.b.median()),
"t值": round(g.gR.mean() / (g.gR.std(ddof=1) / np.sqrt(len(g))), 2),
})
print(pd.DataFrame(rows).to_string(index=False))
print("\n⚠️ B3 是口径校验锚:HANDOFF §4 记的是 PF 0.66 / 胜率 27.4%"
"这里若明显更好,先怀疑接错了再信 B1/B2。")
if "f_g" not in x.columns:
return
print("\n" + "=" * 100)
print("########## 六、反向对照:信号是「指反了」还是「没有边」 ##########")
print("胜率 18.6% 远低于 SL2/TP3 的随机基准约 40%,说明样本带信息。若只是"
"方向标反了,\n反手做应显著为正;若反手也不赚,那就是入场时点本身"
"已经过期,两边都吃滑点。")
y = x.copy()
y["fnet"] = y.f_g.values - fee_of(y.f_r.values, y.f_c.values)
y["fgR"] = y.f_g.values / (SL * y.atr_pct.values)
rows = []
for name, g in y.groupby("type"):
if len(g) < 40:
continue
w, o = g.fnet[g.fnet > 0].sum(), -g.fnet[g.fnet <= 0].sum()
rows.append({
"类型(反手)": name, "笔数": len(g),
"胜率": f"{(g.fnet > 0).mean()*100:.1f}%",
"毛R": round(g.fgR.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"t值": round(g.fgR.mean() / (g.fgR.std(ddof=1) / np.sqrt(len(g))),
2),
})
print(pd.DataFrame(rows).to_string(index=False))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--tfs", default="5m,15m",
help="要测的周期。1m 上滞后的绝对根数与长周期相同,"
"但每根值的钱不同,所以周期是关键变量")
ap.add_argument("--rows", type=int, default=200_000)
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
d = pd.read_feather(OUT)
else:
syms = [s.strip() for s in args.symbols.split(",")]
tfs = [t.strip() for t in args.tfs.split(",")]
print(f"[一二类可行性探针] {len(syms)}× {tfs} × {args.rows}"
f"full 模式,find_all_bsp 要 macd_hist\n", flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, args.rows, t): (s, t)
for s in syms for t in tfs}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
s, t = fut[f]
print(f" [{i}/{len(fut)}] {s} {t} "
f"{0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
d.to_feather(OUT)
d["date_ext"] = pd.to_datetime(d["date_ext"])
for tf, x in d.groupby("tf"):
span = (x.date_ext.max() - x.date_ext.min()).total_seconds() / 86400
print("\n" + "#" * 100)
print(f"########## {tf} —— {len(x)} 个买卖点 · "
f"{x.sym.nunique()} 币 · 跨 {span:.0f} 天 ##########")
report(x, span, x.sym.nunique())
profit(x)
if __name__ == "__main__":
main()
+224
View File
@@ -0,0 +1,224 @@
"""实时版一类买卖点(fast B1/S1)能否翻正。
step55 已判定引擎的 B1/S1 不可用5m PF 0.24/0.20胜率 18.6%/14.8%
t 17/22且病根是滞后 8~9 根带来的几何劣势不是参数B3 当年同病
PF 0.66靠重新定义成实时判据翻到 1.59B4本脚本对一类做同样的尝试
对照组三条缺一不可
引擎 B1/S1 step55 的数说明不改判据是什么下场
fast B1/S1 本次
fast B3/S3 同一份数据同一套出场跑 B4确认管线本身能跑出正数
少了它fast B1 若为负就分不清是判据不行还是管线接错了
"""
from __future__ import annotations
import argparse
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step56_fast_bsp1.feather"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
def collect(sym: str, rows: int, tf: str) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
ensure_timestamp,
find_fast_bsp3,
zones_from_zs_list,
)
from lib.data import fetch_ohlcv
from lib.exit_model import cfg_name, walk_exits
from lib.fast_bsp1 import find_fast_bsp1, zones_with_enter_area
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
if df is None or len(df) < 5_000:
return None
chan = TF_DF(df, 1, tf)
cdf = ensure_timestamp(chan.dataframe)
zs_list = chan.cal_bi_zs_list_pure(chan.bi_list)
if not zs_list:
return None
atr = cdf["atr"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
parts = []
d1 = {}
s1 = find_fast_bsp1(cdf, zones_with_enter_area(zs_list, cdf), diag=d1)
if not s1.empty:
s1["kind"] = "fastB1"
parts.append(s1)
# 同数据同出场跑一遍 B4,作为「管线能出正数」的存在性证明
s3 = find_fast_bsp3(cdf, zones_from_zs_list(zs_list, cdf))
if not s3.empty:
s3["kind"] = "fastB3"
parts.append(s3)
if not parts:
return None
r = pd.concat(parts, ignore_index=True)
r = r[(r.entry_idx < len(cdf) - 2)
& np.isfinite(atr[r.entry_idx.values])
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
if r.empty:
return None
res = walk_exits(cdf, r[["entry_idx", "direction"]], [SL], [RUNNER],
[MAXB], scale_at=SCALE_AT, runners=(RUNNER,),
runner_stops=(RSTOP,))
if len(res) != len(r):
return None
for k in ("g", "r", "c", "b"):
r[k] = res[f"{cfg}_{k}"].to_numpy()
r["sym"], r["tf"] = sym, tf
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
r["date"] = cdf["date"].to_numpy()[r.entry_idx.values]
r["diag"] = str(d1)
return r
except Exception as e: # noqa: BLE001
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
return None
def stats(g: pd.DataFrame) -> dict:
from lib.exit_model import fee_of, taker_notional
net = g.g.values - fee_of(g.r.values, g.c.values)
gR = g.g.values / (SL * g.atr_pct.values)
R = net / (SL * g.atr_pct.values)
tn = taker_notional(g.r.values, g.c.values)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
return {
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"R夏普": round(R.mean() / R.std(ddof=1), 3),
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
"中位持仓": int(np.median(g.b.values)),
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
}
def report(d: pd.DataFrame) -> None:
for tf, x in d.groupby("tf"):
span = (pd.to_datetime(x.date).max()
- pd.to_datetime(x.date).min()).total_seconds() / 86400
print("\n" + "#" * 92)
print(f"########## {tf} · {x.sym.nunique()} 币 · 跨 {span:.0f}"
f" ##########")
rows = []
for (kind, dirn), g in x.groupby(["kind", "direction"]):
if len(g) < 40:
continue
nm = {"fastB1": ("一买", "一卖"), "fastB3": ("三买", "三卖")}[kind]
rows.append({"信号": f"{kind} {nm[0 if dirn == 1 else 1]}",
"每币每天": round(len(g) / span / x.sym.nunique(), 3),
**stats(g)})
if rows:
print(pd.DataFrame(rows).to_string(index=False))
print("\n对照 · step55 引擎原生(同周期同出场):")
print(" 5m B1 PF 0.24 胜率 18.6% t 17.3 S1 PF 0.20 胜率 14.8% "
"t 22.3")
print(" 15m B1 PF 0.21 t 18.9 S1 PF 0.23 t 15.9")
f1 = x[x.kind == "fastB1"]
if len(f1) >= 120 and "ext_atr" in f1.columns:
y = f1.dropna(subset=["ext_atr"])
print("\n延伸度分档(ext_atr,step62 在滞后版上实测的唯一单调特征:"
"\n命中线段顶点的比例 12%→44%PF 0.12→0.46):")
q = pd.qcut(y["ext_atr"], 4,
labels=["Q1最短", "Q2", "Q3", "Q4最延伸"],
duplicates="drop")
print(pd.DataFrame([{"": k, **stats(v)}
for k, v in y.groupby(q, observed=True)
if len(v) >= 30]).to_string(index=False))
print("\n⭐ 组合:低滞后(本模块)+ 延伸过滤(step62)—— "
"唯一同时处理两个约束的路径")
p50, p75 = y["ext_atr"].quantile(.50), y["ext_atr"].quantile(.75)
dm = y["div"].median()
rows = [{"过滤器": "", **stats(y)}]
for nm, g in [
(f"ext≥P50({p50:.1f})", y[y["ext_atr"] >= p50]),
(f"ext≥P75({p75:.1f})", y[y["ext_atr"] >= p75]),
(f"ext≥P75 且 div≥中位", y[(y["ext_atr"] >= p75)
& (y["div"] >= dm)]),
(f"ext≥P50 且 div≥中位", y[(y["ext_atr"] >= p50)
& (y["div"] >= dm)]),
]:
if len(g) >= 30:
rows.append({"过滤器": nm, **stats(g)})
print(pd.DataFrame(rows).to_string(index=False))
print("对照 · 同过滤器在滞后版(step62,5m):无 PF 0.22 → "
"ext≥P75且div≥中位 PF 0.54")
if len(f1) >= 60:
print("\n背驰强度分档(div = 离开段面积/进入段面积,越小背驰越强):")
q = pd.qcut(f1["div"], 4, labels=["Q1最强", "Q2", "Q3", "Q4最弱"],
duplicates="drop")
print(pd.DataFrame([{"": k, **stats(v)}
for k, v in f1.groupby(q, observed=True)
if len(v) >= 20]).to_string(index=False))
print("\n滞后分档(lag = 入场根 − 离开段极值根):")
b = pd.cut(f1["lag"], [-1, 1, 3, 6, 12, 1e9],
labels=["≤1根", "2-3根", "4-6根", "7-12根", ">12根"])
print(pd.DataFrame([{"": k, **stats(v)}
for k, v in f1.groupby(b, observed=True)
if len(v) >= 20]).to_string(index=False))
dg = d[d.kind == "fastB1"].diag.dropna()
if len(dg):
print("\n" + "=" * 92)
print("fast B1 漏斗(首个中枢样本):", dg.iloc[0])
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--tfs", default="5m,15m")
ap.add_argument("--rows", type=int, default=200_000)
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
d = pd.read_feather(OUT)
else:
syms = [s.strip() for s in args.symbols.split(",")]
tfs = [t.strip() for t in args.tfs.split(",")]
print(f"[实时版一类] {len(syms)}× {tfs} × {args.rows}\n",
flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, args.rows, t): (s, t)
for s in syms for t in tfs}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
s, t = fut[f]
print(f" [{i}/{len(fut)}] {s} {t} "
f"{0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d)
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
"""一类反手(fade B1/S1)是不是真的——逐币、分时段、与 B4 的重叠。
step55 的反向对照给出 5m/15m PF 2.35~3.03t +10~+13且多空两边都正
数字太好先按 §3.392 的教训做证伪那次周末效应 1m 上显著换到
7 5m/15m 样本就消失且符号反转任何单一样本上的漂亮结果都要先过三关
逐币 5 个币是不是都成立还是被某一个币带起来的
分时段 前后半段样本各自是否成立时间外样本
B4 重叠 如果它只是换个名字的 B4那就没有增量价值
"""
from __future__ import annotations
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
from lib.exit_model import fee_of, taker_notional # noqa: E402
SRC = HERE / "out" / "step55_bsp12.feather"
SL = 2.0
def stats(g: pd.DataFrame) -> dict:
"""反手口径的统计。f_* 是 step55 里用相反方向重跑出场得到的。"""
net = g.f_g.values - fee_of(g.f_r.values, g.f_c.values)
gR = g.f_g.values / (SL * g.atr_pct.values)
R = net / (SL * g.atr_pct.values)
tn = taker_notional(g.f_r.values, g.f_c.values)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
return {
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
}
def main() -> None:
d = pd.read_feather(SRC)
if "f_g" not in d.columns:
print("step55 数据里没有反手列,先重跑 step55")
return
d["date_ext"] = pd.to_datetime(d["date_ext"])
one = d[d.type.isin(["B1", "S1"])].copy()
for tf, x in one.groupby("tf"):
print("\n" + "#" * 92)
print(f"########## {tf} · 一类反手 ##########")
print("\n【逐币】任何一个币独自撑起来的结果都不能要")
print(pd.DataFrame([{"": s, **stats(g)}
for s, g in x.groupby("sym")]).to_string(index=False))
print("\n【分时段】按信号时间中位数切两半,后半段是时间外样本")
cut = x.date_ext.median()
for nm, g in (("前半", x[x.date_ext <= cut]), ("后半", x[x.date_ext > cut])):
r = stats(g)
print(f" {nm} {g.date_ext.min():%Y-%m-%d}~{g.date_ext.max():%Y-%m-%d}"
f" {r}")
print("\n【分方向】B1反手=做空,S1反手=做多。只有一边成立就是单边行情")
print(pd.DataFrame([{"原类型": t, **stats(g)}
for t, g in x.groupby("type")]).to_string(index=False))
print("\n【逐年】")
rows = []
for y, g in x.groupby(x.date_ext.dt.year):
if len(g) >= 40:
rows.append({"": y, **stats(g)})
if rows:
print(pd.DataFrame(rows).to_string(index=False))
if __name__ == "__main__":
main()
+196
View File
@@ -0,0 +1,196 @@
"""结构止损:一类做多是被「止损太紧」打死的,还是趋势真的继续?
用户的观察B1 做多的入场价 P 在结构低点 L 上方约 1.68 ATR而固定止损是
P 2 ATR **只在 L 下方 0.32 ATR**缠论里 B2 正是回踩不破 L的那次
机会属于预期之内的正常回抽止损只留 0.32 ATR一根插针就打掉而那恰恰
是该加仓的位置
这与趋势继续向下是两个不同的失败模式**预测相反**
止损太紧 把止损放到 L 下方足够远 -> 多头应被救活
趋势继续 放宽止损只是亏得更多且反手做空应持续为正
本脚本用逐笔的结构止损挂在 L 外侧 margin ATR重跑直接区分这两者
`walk_exits` 只支持全局固定止损故这里自带模拟器出场结构与实盘一致
减半于 SCALE_AT剩余半仓目标 RUNNER剩余半仓止损回到初始止损MAXB 超时
"""
from __future__ import annotations
import argparse
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
from lib.data import fetch_ohlcv # noqa: E402
from lib.exit_model import fee_of, taker_notional # noqa: E402
SRC = HERE / "out" / "step55_bsp12.feather"
SCALE_AT, RUNNER, MAXB = 3.0, 8.0, 48
def simulate(cdf: pd.DataFrame, trades: pd.DataFrame,
stop_atr: np.ndarray, r_scale: bool = True) -> pd.DataFrame:
"""逐笔止损宽度的出场模拟。stop_atr 是每笔各自的初始止损(ATR 倍数)。
`lib.exit_model.walk_exits` 的结构对齐先减半仓于 SCALE_AT剩余半仓
RUNNER 或回到初始止损未减仓则整仓在止损/超时了结同根内止损优先于
获利目标 分辨不了根内先后时按不利的一侧算不给回测送分
`r_scale=True` 时目标按 **R 倍数**而非 ATR 固定值放置实盘的 2/3/8 等于
1.5R 减半4R 收尾放宽止损却不放大目标会把盈亏比压到 1 以下
那样测出来的放宽无效是自证的默认按 R 等比放大才是公平对照
"""
k_scale = SCALE_AT / 2.0 if r_scale else None
k_run = RUNNER / 2.0 if r_scale else None
high = cdf["high"].to_numpy(float)
low = cdf["low"].to_numpy(float)
open_ = cdf["open"].to_numpy(float)
close = cdf["close"].to_numpy(float)
n = len(cdf)
out = []
ent = trades.entry_idx.astype(int).to_numpy()
dirs = trades.direction.astype(int).to_numpy()
atrs = trades.atr_at_entry.to_numpy(float)
for k in range(len(trades)):
s, d, a, sl = ent[k], dirs[k], atrs[k], stop_atr[k]
e = s + 1
if e >= n - 1 or not np.isfinite(sl) or sl <= 0 or not np.isfinite(a):
out.append((np.nan, "skip", 0, 0))
continue
tgt = sl * k_scale if r_scale else SCALE_AT
run = sl * k_run if r_scale else RUNNER
entry = open_[e]
end = min(e + MAXB, n - 1)
half_done = False
g, why, bars = None, "timeout", end - e
for j in range(e, end + 1):
adv = (high[j] - entry) / a if d == 1 else (entry - low[j]) / a
ret = (entry - low[j]) / a if d == 1 else (high[j] - entry) / a
if ret >= sl: # 同根内止损优先
g = ((-sl * a) / entry) * (0.5 if half_done else 1.0)
if half_done:
g += (tgt * a / entry) * 0.5
why, bars = "stop", j - e
break
if not half_done and adv >= tgt:
half_done = True
if half_done and adv >= run:
g = (tgt * 0.5 + run * 0.5) * a / entry
why, bars = "target", j - e
break
if g is None:
px = close[end]
r = (px - entry) * d / entry
g = (tgt * a / entry) * 0.5 + r * 0.5 if half_done else r
why, bars = ("timeout_half" if half_done else "timeout"), end - e
out.append((g, why, int(half_done), bars))
return pd.DataFrame(out, columns=["g", "r", "c", "b"])
def stats(g: pd.Series, r: pd.Series, c: pd.Series, atr_pct: np.ndarray,
sl_used: np.ndarray) -> dict:
net = g.values - fee_of(r.values, c.values)
# R 用每笔自己的止损宽度归一,否则宽止损会被系统性低估风险
denom = sl_used * atr_pct
R = net / denom
tn = taker_notional(r.values, c.values)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
return {
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
"净均R": round(np.nanmean(R), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
"t值": round(np.nanmean(R) / (np.nanstd(R, ddof=1)
/ np.sqrt(len(R))), 2),
}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--tf", default="5m")
ap.add_argument("--rows", type=int, default=200_000)
# step65 发现线段中位幅度只有 11.4 ATR,而 r_scale 会把 3.9 ATR 的结构止损
# 对应的 runner 目标推到 15.6 ATR —— 比整段行情还长,永远打不到。
# 本脚本首轮全程用了 r_scale=True,等于「放宽止损」和「目标够不着」同时生效,
# 两个效应互相抵消,那轮的结论无效。绝对目标才是与 11.4 ATR 相容的口径。
ap.add_argument("--abs-targets", action="store_true",
help="目标位用绝对 ATR(3/8)而非按止损等比放大")
args = ap.parse_args()
d = pd.read_feather(SRC)
if "ext_px" not in d.columns:
print("step55 数据缺 ext_px/entry_px,先重跑 step55")
return
d = d[(d.tf == args.tf) & d.type.isin(["B1", "S1"])].copy()
print(f"[结构止损] {args.tf} · 一类 {len(d)}\n")
print("=" * 92)
print("########## 一、几何:固定 2ATR 止损离结构位有多远 ##########")
# 入场价到结构极值的距离,用入场根的 ATR 归一(与 walk_exits 同口径)
d["to_ext"] = (d.entry_px - d.ext_px) * d.dir / d.atr_at_entry
d["margin_2atr"] = 2.0 - d.to_ext
print(f"入场价到结构极值 (ATR) 中位 {d.to_ext.median():.2f} "
f"P25 {d.to_ext.quantile(.25):.2f} P75 {d.to_ext.quantile(.75):.2f}")
print(f"2ATR 止损在结构位外侧留的余地 (ATR) 中位 "
f"{d.margin_2atr.median():.2f}")
inside = (d.margin_2atr <= 0).mean()
print(f"**止损落在结构位以内(还没到极值就被打掉)的占比:{inside*100:.1f}%**")
print(" —— 这部分交易,价格连回踩到前低都不用,就已经出局了。")
print("\n" + "=" * 92)
print("########## 二、把止损挂到结构位外侧,多头能不能救活 ##########")
print("margin = 止损挂在结构极值外侧几个 ATR。对照组是现行的固定 2 ATR。")
from chanlun import TF_DF
rows = []
cache = {}
for sym in sorted(d.sym.unique()):
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
cache[(sym, args.tf)] = TF_DF(df, 1, args.tf).dataframe
print(f"目标位口径:{'绝对 3/8 ATR(装得进 11.4 ATR 的线段)' if args.abs_targets else '随止损等比放大(恒定 1.5R/4R'}")
for flip in (False, True):
for label, margin in [("固定2ATR", None), ("结构+0.25", 0.25),
("结构+0.5", 0.5), ("结构+1.0", 1.0),
("结构+1.5", 1.5)]:
gs, rs, cs, ap_, sl_ = [], [], [], [], []
for sym, x in d.groupby("sym"):
cdf = cache[(sym, args.tf)]
x = x.reset_index(drop=True)
t = pd.DataFrame({
"entry_idx": x.i_sure.values,
"direction": (-x.dir.values if flip else x.dir.values),
"atr_at_entry": x.atr_at_entry.values})
# 反手时结构位在**盈利**方向,不能拿它当止损;止损改挂在
# 入场价另一侧同样宽度处,否则两组比的不是同一个东西
sl = (np.full(len(x), 2.0) if margin is None
else (x.to_ext.values + margin))
res = simulate(cdf, t, sl, r_scale=not args.abs_targets)
ok = res.g.notna().values
gs.append(res.g[ok])
rs.append(res.r[ok])
cs.append(res.c[ok])
ap_.append((x.atr_at_entry.values / x.entry_px.values)[ok])
sl_.append(sl[ok])
rows.append({"方向": "反手" if flip else "原方向", "止损": label,
"中位宽度ATR": round(float(np.median(
np.concatenate(sl_))), 2),
**stats(pd.concat(gs, ignore_index=True),
pd.concat(rs, ignore_index=True),
pd.concat(cs, ignore_index=True),
np.concatenate(ap_), np.concatenate(sl_))})
print(pd.DataFrame(rows).to_string(index=False))
print("\n判读:若「止损太紧」是主因,放宽到结构位外侧应让原方向 PF 越过 1。"
"\n若原方向放宽后仍在 1 以下、而反手始终显著为正,"
"说明趋势确实在继续,\n那么该做的是反手,加宽止损只是少亏一点。")
if __name__ == "__main__":
main()
+236
View File
@@ -0,0 +1,236 @@
"""因果性回放:一类反手的信号在当时真的发得出来吗?
§3.395 PF 2.3~3.0 建立在全量数据一次算完的 `bsp_list` 但增量模块的
文件头自己写着笔必须整表重扫**最后一笔 is_sure 允许收回**§5.41 也记了
中枢右边缘会重画若信号是事后才浮现的那个 PF 就是幻觉
**做法** `init_stream` 预热随后逐根 `append_bar`每根之后重算
`cal_bi_zs_list_pure` + `find_all_bsp`记录每个信号**第一次出现**在哪一根
入场用那一根的次根开盘而不是事后的 `sure_time` 实盘只能这样
必须逐根不能分段重建在第 t 根用 `data[0:t+S]` 重算等于多给了 S 根的信息
测出来的因果性是假的
**三个要看的量**
召回 全量算出的信号有多少在回放中真的出现过没出现的是事后才浮现
幻影 回放中出现但全量里没有的当时发了后来被重画掉
代价 回放首现根 vs 全量 sure_time 的滞后以及按首现根入场的实际 PF
"""
from __future__ import annotations
import argparse
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step59_replay.feather"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
WANT = {"B1": 1, "S1": -1}
def sig_key(b) -> tuple | None:
"""信号身份用「类型 + 极值 KLC 的结束时刻」。
不能用 sure_time 当身份它正是会被重画的字段用它做键会把同一个信号
在不同根上算成两个极值点稳定得多
"""
t = getattr(b.type, "name", str(b.type))
if t not in WANT:
return None
return (t, str(b.klc.end_time))
def replay(sym: str, tf: str, rows: int, warm: int, steps: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from lib.data import fetch_ohlcv
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
if df is None or len(df) < warm + steps + 100:
print(f" {sym} {tf} 数据不足 {0 if df is None else len(df)}", flush=True)
return None
df = df.iloc[-(warm + steps):].reset_index(drop=True)
# ---- 全量口径:一次算完,作为对照 ----
full = TF_DF(df, 1, tf, lean=False)
fz = full.cal_bi_zs_list_pure(full.bi_list)
full_sig = {}
for b in (full.find_all_bsp(full.bi_list, fz) or []):
k = sig_key(b)
if k and b.sure_time is not None:
full_sig[k] = str(b.sure_time)
# ---- 回放口径:逐根追加,记录首现根 ----
chan = TF_DF(df.iloc[:warm].copy(), 1, tf, lean=False)
chan.init_stream(df.iloc[:warm].copy(), 1, tf)
first_seen: dict[tuple, int] = {}
for i in range(warm, len(df)):
chan.append_bar(df.iloc[i])
try:
zs = chan.cal_bi_zs_list_pure(chan.bi_list)
bsp = chan.find_all_bsp(chan.bi_list, zs) if zs else []
except Exception: # noqa: BLE001
continue
for b in (bsp or []):
k = sig_key(b)
if k and k not in first_seen:
first_seen[k] = i
dser = pd.to_datetime(full.dataframe["date"])
if dser.dt.tz is not None:
dser = dser.dt.tz_localize(None)
didx = pd.DatetimeIndex(dser)
def to_i(ts) -> int:
t = pd.Timestamp(ts)
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
rec = []
for k in set(full_sig) | set(first_seen):
t, ext_t = k
i_seen = first_seen.get(k)
# 只统计回放窗口内的:预热段的信号本来就不在考察范围
i_ext = to_i(ext_t)
if i_ext < warm - 200:
continue
rec.append({
"sym": sym, "tf": tf, "type": t, "dir": WANT[t],
"in_full": k in full_sig, "in_replay": i_seen is not None,
"i_ext": i_ext,
"i_seen": -1 if i_seen is None else i_seen,
"i_sure_full": to_i(full_sig[k]) if k in full_sig else -1,
})
r = pd.DataFrame(rec)
if r.empty:
return None
# 按回放首现根入场,跑与实盘一致的出场
from lib.exit_model import cfg_name, walk_exits
cdf = full.dataframe
atr = cdf["atr"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
live = r[r.in_replay & (r.i_seen < len(cdf) - 2)].copy()
live = live[np.isfinite(atr[live.i_seen.values])
& (atr[live.i_seen.values] > 0)]
if not live.empty:
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
# 反手:§3.395 判定该反着做
t_ = pd.DataFrame({"entry_idx": live.i_seen.values,
"direction": -live.dir.values})
res = walk_exits(cdf, t_, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
runners=(RUNNER,), runner_stops=(RSTOP,))
if len(res) == len(live):
for c in ("g", "r", "c"):
live[c] = res[f"{cfg}_{c}"].to_numpy()
live["atr_pct"] = atr[live.i_seen.values] / cl[live.i_seen.values]
r = r.merge(live[["i_ext", "type", "g", "r", "c", "atr_pct"]],
on=["i_ext", "type"], how="left")
return r
def report(d: pd.DataFrame) -> None:
from lib.exit_model import fee_of, taker_notional
print("\n" + "=" * 92)
print("########## 一、召回与幻影 ##########")
rows = []
for (tf, t), x in d.groupby(["tf", "type"]):
full = x[x.in_full]
rep = x[x.in_replay]
both = x[x.in_full & x.in_replay]
rows.append({
"tf": tf, "类型": t,
"全量信号": len(full), "回放信号": len(rep),
"召回": f"{len(both)/max(len(full),1)*100:.1f}%",
"事后才浮现": len(full) - len(both),
"幻影(被重画掉)": len(rep) - len(both),
})
print(pd.DataFrame(rows).to_string(index=False))
print("\n召回 = 全量算出的信号里,回放中真的出现过的比例。"
"\n幻影 = 回放中发过、全量里却没有的 —— 实盘会照做,回测却看不见它。")
print("\n" + "=" * 92)
print("########## 二、时点代价:回放首现 vs 全量 sure_time ##########")
b = d[d.in_full & d.in_replay].copy()
b["delay"] = b.i_seen - b.i_sure_full
for tf, x in b.groupby("tf"):
q = x.delay.quantile([.25, .5, .75, .9])
print(f" {tf} 中位 {q[.5]:+.0f} 根 P25 {q[.25]:+.0f} "
f"P75 {q[.75]:+.0f} P90 {q[.9]:+.0f} "
f" 早于或等于全量的占比 {(x.delay <= 0).mean()*100:.0f}%")
print(" 正值 = 回放比全量晚知道,实盘要在更差的价位入场。")
if "g" not in d.columns:
return
print("\n" + "=" * 92)
print("########## 三、真正能落地的收益:按回放首现根入场(反手)##########")
x = d.dropna(subset=["g"]).copy()
rows = []
for (tf, t), g in x.groupby(["tf", "type"]):
if len(g) < 30:
continue
net = g.g.values - fee_of(g.r.values, g.c.values)
gR = g.g.values / (SL * g.atr_pct.values)
R = net / (SL * g.atr_pct.values)
tn = taker_notional(g.r.values, g.c.values)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
rows.append({
"tf": tf, "类型(反手)": t, "笔数": len(g),
"胜率": f"{(net > 0).mean()*100:.1f}%",
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
})
if rows:
print(pd.DataFrame(rows).to_string(index=False))
print("\n对照 · §3.395 全量口径 5mB1反手 PF 2.35 / S1反手 2.75")
print("若这里明显掉下来,说明那个 PF 吃了右边缘重画的红利,不可落地。")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL")
ap.add_argument("--tf", default="5m")
ap.add_argument("--rows", type=int, default=60_000)
ap.add_argument("--warm", type=int, default=20_000)
ap.add_argument("--steps", type=int, default=10_000)
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
report(pd.read_feather(OUT))
return
syms = [x.strip() for x in args.symbols.split(",")]
print(f"[因果回放] {len(syms)}× {args.steps} 根逐根重放"
f"(每根都要重算笔中枢与 bsp,慢是必然的)\n", flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(replay, s, args.tf, args.rows, args.warm,
args.steps): s for s in syms}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
print(f" [{i}/{len(syms)}] {fut[f]} "
f"{0 if r is None else len(r)} 个信号", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d)
if __name__ == "__main__":
main()
+242
View File
@@ -0,0 +1,242 @@
"""一类买卖点到底有没有找到真正的趋势反转点?用线段顶点当标准答案。
用户提出线段的终点就是该级别的趋势反转点虽然它是未来函数但可以拿来当
**标签**验证检测器而不是拿来交易这能把两件事分开
检测器对不对 B1 是否真的落在趋势反转底上
能不能交易 step59 已证否实盘首现比 sure_time 晚中位 15 PF 塌到 0.92
若检测器对而只是慢那问题是延迟还有救若检测器本身就没找到反转点
这条线整个是死的
**对照组是这个测试的全部意义**B1 按构造就长在笔的低点上而笔低点本来
就有一定概率撞上线段底所以要问的不是B1 命中率多少而是
**在所有同向笔端点里B1 这个标签把命中率提高了多少倍**
没有这个基准任何绝对数字都可以随便解读
同样的坑 fast_bsp 文档头踩过回抽极值命中笔端点 19.3%看着不低
但随机基准是 22%其实是负贡献
"""
from __future__ import annotations
import argparse
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step60_seg_truth.feather"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
TOL = [0, 1, 2, 3, 5]
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
from lib.data import fetch_ohlcv
from lib.exit_model import cfg_name, walk_exits
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
if df is None or len(df) < 5_000:
return None
chan = TF_DF(df, 1, tf, lean=False)
cdf = chan.dataframe
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
if not bz:
return None
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
dser = pd.to_datetime(cdf["date"])
if dser.dt.tz is not None:
dser = dser.dt.tz_localize(None)
didx = pd.DatetimeIndex(dser)
n = len(cdf)
def to_i(ts) -> int:
t = pd.Timestamp(ts)
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
# ---- 标准答案:线段终点。下降线段终点=真底,上升线段终点=真顶 ----
seg_bot, seg_top = [], []
for sg in getattr(chan, "seg_list", []) or []:
if sg.end_time is None:
continue
i = to_i(sg.end_time)
if not (0 <= i < n):
continue
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN else seg_top).append(i)
if not seg_bot or not seg_top:
return None
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
def near(i: int, d: int, tol: int) -> bool:
a = truth[d]
k = int(np.searchsorted(a, i))
for j in (k - 1, k):
if 0 <= j < len(a) and abs(int(a[j]) - i) <= tol:
return True
return False
rec = []
# ---- 对照组:所有笔端点。B1 本就长在笔低点上,基准必须同源 ----
for bi in chan.bi_list:
if not getattr(bi, "is_sure", False) or bi.end_klc is None:
continue
d = 1 if str(bi.dir).endswith("DOWN") else -1 # 下降笔终点=低点
i = to_i(bi.end_klc.end_time)
if not (0 <= i < n):
continue
rec.append({"kind": "笔端点", "dir": d, "i_ext": i, "i_sure": -1})
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
for b in bsp:
tag = want.get(b.type)
if tag is None or b.sure_time is None:
continue
name, d = tag
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
if not (0 <= i_ext < n and 0 <= i_sure < n):
continue
rec.append({"kind": name, "dir": d, "i_ext": i_ext,
"i_sure": i_sure})
r = pd.DataFrame(rec)
for tol in TOL:
r[f"hit{tol}"] = [near(i, d, tol)
for i, d in zip(r.i_ext, r.dir)]
# 命中线段顶点的那批一类,按原方向(抄底)做能不能赚
atr = cdf["atr"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
sig = r[(r.kind.isin(["B1", "S1"])) & (r.i_sure >= 0)
& (r.i_sure < n - 2)].copy()
sig = sig[np.isfinite(atr[sig.i_sure.values])
& (atr[sig.i_sure.values] > 0)]
if not sig.empty:
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
res = walk_exits(cdf, pd.DataFrame({
"entry_idx": sig.i_sure.values,
"direction": sig.dir.values}), [SL], [RUNNER], [MAXB],
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
if len(res) == len(sig):
for c in ("g", "r", "c"):
sig[c] = res[f"{cfg}_{c}"].to_numpy()
sig["atr_pct"] = (atr[sig.i_sure.values]
/ cl[sig.i_sure.values])
r = r.merge(sig[["i_ext", "kind", "g", "r", "c", "atr_pct"]],
on=["i_ext", "kind"], how="left")
r["sym"], r["tf"] = sym, tf
return r
except Exception as e: # noqa: BLE001
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
return None
def report(d: pd.DataFrame) -> None:
from lib.exit_model import fee_of, taker_notional
for tf, x in d.groupby("tf"):
print("\n" + "#" * 92)
print(f"########## {tf} · 线段顶点作为标准答案 ##########")
print("\n【命中率】i_ext 落在同向线段终点 ±tol 根内的比例")
rows = []
for kind in ["笔端点", "B1", "S1", "B3", "S3"]:
g = x[x.kind == kind]
if len(g) < 30:
continue
row = {"信号": kind, "样本": len(g)}
for tol in TOL:
row[f"±{tol}"] = f"{g[f'hit{tol}'].mean()*100:.1f}%"
rows.append(row)
t = pd.DataFrame(rows)
print(t.to_string(index=False))
base = x[x.kind == "笔端点"]
print("\n【提升倍数】相对「所有同向笔端点」这个基准。"
"≈1 就是没有信息量")
rows = []
for kind in ["B1", "S1", "B3", "S3"]:
g = x[x.kind == kind]
if len(g) < 30:
continue
row = {"信号": kind}
for tol in TOL:
b = base[base.dir.isin(g.dir.unique())][f"hit{tol}"].mean()
row[f"±{tol}"] = (round(g[f"hit{tol}"].mean() / b, 2)
if b > 0 else np.nan)
rows.append(row)
print(pd.DataFrame(rows).to_string(index=False))
if "g" not in x.columns:
continue
print("\n【命中 vs 未命中】一类按原方向(抄底/摸顶)做的表现,"
"tol=±2 根")
y = x[x.kind.isin(["B1", "S1"])].dropna(subset=["g"]).copy()
if len(y) < 60:
continue
rows = []
for hit, g in y.groupby(y.hit2):
net = g.g.values - fee_of(g.r.values, g.c.values)
gR = g.g.values / (SL * g.atr_pct.values)
tn = taker_notional(g.r.values, g.c.values)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
rows.append({
"命中线段顶点": "" if hit else "", "笔数": len(g),
"胜率": f"{(net > 0).mean()*100:.1f}%",
"毛R": round(gR.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
"t值": round(gR.mean() / (gR.std(ddof=1)
/ np.sqrt(len(g))), 2),
})
print(pd.DataFrame(rows).to_string(index=False))
print("\n判读:若「命中」那组按原方向做显著为正,说明检测器是对的、"
"只是掺了太多噪声,\n值得找实时可判的过滤器;若两组都为负,"
"说明即使真站在线段底上,\n这个入场时点也已经太晚了。")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--tfs", default="5m,15m")
ap.add_argument("--rows", type=int, default=200_000)
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
report(pd.read_feather(OUT))
return
syms = [s.strip() for s in args.symbols.split(",")]
tfs = [t.strip() for t in args.tfs.split(",")]
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, t, args.rows): (s, t)
for s in syms for t in tfs}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
s, t = fut[f]
print(f" [{i}/{len(fut)}] {s} {t} "
f"{0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d)
if __name__ == "__main__":
main()
+252
View File
@@ -0,0 +1,252 @@
"""极值点的波动率:它是不是既拖慢了确认,又让目标够不着?
用户的观察一二类买卖点长在极值点上那个区域波动天然很大这可能正是确认
慢的原因
这个假设若成立会同时解释两件事而且机制不同
确认慢 波动大 -> 分型/笔要更多根才稳定下来 -> sure_time 更晚
赚不到 ATR 被造成极值的那根插针抬高 -> 止损 2×虚高ATR 其实宽松
但目标 3/8 ATR 变得够不着因为入场后波动率会均值回复下来
第二条尤其要紧它意味着交易不是被打掉而是永远走不到目标
§3.397 判定的趋势继续**不同的失败模式**应对办法也不同
该换波动率口径而不是换方向
三个量
atr_z 极值处 ATR / 该点之前 200 根的中位 ATR 是否真的偏高
atr_fwd 入场后 48 根的平均 ATR / 入场时 ATR 是否均值回复
与滞后相关 atr_z 高的信号lag_bars 是否更长
"""
from __future__ import annotations
import argparse
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step61_vol.feather"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
BASE_WIN = 200
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.core.ChanEnum import Chan_BSP_TYPE
from lib.data import fetch_ohlcv
from lib.exit_model import cfg_name, walk_exits
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
if df is None or len(df) < 5_000:
return None
chan = TF_DF(df, 1, tf, lean=False)
cdf = chan.dataframe
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
if not bz:
return None
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
dser = pd.to_datetime(cdf["date"])
if dser.dt.tz is not None:
dser = dser.dt.tz_localize(None)
didx = pd.DatetimeIndex(dser)
n = len(cdf)
def to_i(ts) -> int:
t = pd.Timestamp(ts)
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
atr = cdf["atr"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
# 基准 ATR 只用**该点之前**的窗口,含当根会把要检验的那根插针算进去
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
.median().shift(1).to_numpy())
rec = []
# 对照组:所有笔端点,与 §3.397 同源
for bi in chan.bi_list:
if not getattr(bi, "is_sure", False) or bi.end_klc is None:
continue
i = to_i(bi.end_klc.end_time)
if not (0 <= i < n) or not np.isfinite(base[i]) or base[i] <= 0:
continue
rec.append({"kind": "笔端点", "dir": 0, "i_ext": i, "i_sure": -1,
"atr_z": atr[i] / base[i], "lag_bars": -1})
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1),
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
for b in bsp:
tag = want.get(b.type)
if tag is None or b.sure_time is None:
continue
name, d = tag
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
if not (0 <= i_ext < n and 0 <= i_sure < n):
continue
if not np.isfinite(base[i_ext]) or base[i_ext] <= 0:
continue
rec.append({"kind": name, "dir": d, "i_ext": i_ext,
"i_sure": i_sure, "atr_z": atr[i_ext] / base[i_ext],
"lag_bars": i_sure - i_ext})
r = pd.DataFrame(rec)
# 入场后的实现波动率:目标够不够得着,取决于入场**之后**的 ATR
ok = r.i_sure >= 0
fwd = np.full(len(r), np.nan)
for k, i in zip(np.where(ok)[0], r.i_sure[ok].values):
j = min(int(i) + 1 + MAXB, n)
if j > int(i) + 1 and atr[int(i)] > 0:
fwd[k] = np.nanmean(atr[int(i) + 1:j]) / atr[int(i)]
r["atr_fwd"] = fwd
sig = r[ok & (r.i_sure < n - 2)].copy()
sig = sig[np.isfinite(atr[sig.i_sure.values])
& (atr[sig.i_sure.values] > 0)]
if not sig.empty:
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
res = walk_exits(cdf, pd.DataFrame({
"entry_idx": sig.i_sure.values,
"direction": sig.dir.values}), [SL], [RUNNER], [MAXB],
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
if len(res) == len(sig):
for c in ("g", "r", "c", "b"):
sig[c] = res[f"{cfg}_{c}"].to_numpy()
sig["atr_pct"] = (atr[sig.i_sure.values]
/ cl[sig.i_sure.values])
r = r.merge(sig[["i_ext", "kind", "g", "r", "c", "b",
"atr_pct"]], on=["i_ext", "kind"], how="left")
r["sym"], r["tf"] = sym, tf
return r
except Exception as e: # noqa: BLE001
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
return None
def report(d: pd.DataFrame) -> None:
from lib.exit_model import fee_of, taker_notional
for tf, x in d.groupby("tf"):
print("\n" + "#" * 96)
print(f"########## {tf} · 极值点的波动率 ##########")
print("\n【一】极值处 ATR 是不是真的偏高(atr_z = 当点ATR / 前200根中位ATR")
rows = []
for kind in ["笔端点", "B1", "S1", "B2", "S2", "B3", "S3"]:
g = x[x.kind == kind]
if len(g) < 30:
continue
rows.append({
"信号": kind, "样本": len(g),
"atr_z中位": round(g.atr_z.median(), 3),
"P75": round(g.atr_z.quantile(.75), 3),
"P90": round(g.atr_z.quantile(.90), 3),
"高于基准占比": f"{(g.atr_z > 1).mean()*100:.0f}%",
})
print(pd.DataFrame(rows).to_string(index=False))
print("\n【二】波动大是不是确认更慢(按 atr_z 四分位看 lag_bars")
y = x[x.kind.isin(["B1", "S1"]) & (x.lag_bars >= 0)].copy()
if len(y) >= 100:
q = pd.qcut(y.atr_z, 4, labels=["Q1低", "Q2", "Q3", "Q4高"],
duplicates="drop")
print(pd.DataFrame([{
"atr_z档": k, "笔数": len(g),
"atr_z中位": round(g.atr_z.median(), 2),
"滞后中位": round(g.lag_bars.median(), 1),
"滞后均值": round(g.lag_bars.mean(), 1),
} for k, g in y.groupby(q, observed=True)]).to_string(index=False))
c = np.corrcoef(y.atr_z, y.lag_bars)[0, 1]
print(f" 相关系数 corr(atr_z, lag_bars) = {c:+.3f}")
print(" 正相关支持「波动大 -> 确认慢」;接近 0 则该假设不成立。")
print("\n【三】入场后波动率是否回落(atr_fwd = 后48根平均ATR / 入场ATR")
rows = []
for kind in ["B1", "S1", "B3", "S3"]:
g = x[(x.kind == kind)].dropna(subset=["atr_fwd"])
if len(g) < 30:
continue
rows.append({
"信号": kind, "样本": len(g),
"atr_fwd中位": round(g.atr_fwd.median(), 3),
"回落占比(<1)": f"{(g.atr_fwd < 1).mean()*100:.0f}%",
})
print(pd.DataFrame(rows).to_string(index=False))
print(" <1 = 入场后波动率比入场那刻低,则按入场ATR定的 3/8 ATR 目标"
"\n 在绝对价格上被高估,会系统性够不着。")
if "g" not in x.columns:
continue
print("\n【四】目标够不着的证据:出场原因分布(一类,按原方向)")
z = x[x.kind.isin(["B1", "S1"])].dropna(subset=["g"])
if len(z) >= 60:
print((z.r.value_counts(normalize=True) * 100).round(1)
.to_frame("占比%").to_string())
print(f" 中位持仓 {z.b.median():.0f} 根 / 上限 {MAXB}")
print("\n【五】按 atr_z 分档看一类表现(原方向)")
q = pd.qcut(z.atr_z, 4, labels=["Q1低", "Q2", "Q3", "Q4高"],
duplicates="drop")
rows = []
for k, g in z.groupby(q, observed=True):
net = g.g.values - fee_of(g.r.values, g.c.values)
gR = g.g.values / (SL * g.atr_pct.values)
tn = taker_notional(g.r.values, g.c.values)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
rows.append({
"atr_z档": k, "笔数": len(g),
"atr_z中位": round(g.atr_z.median(), 2),
"胜率": f"{(net > 0).mean()*100:.1f}%",
"毛R": round(gR.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
})
print(pd.DataFrame(rows).to_string(index=False))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--tfs", default="5m,15m")
ap.add_argument("--rows", type=int, default=200_000)
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
report(pd.read_feather(OUT))
return
syms = [s.strip() for s in args.symbols.split(",")]
tfs = [t.strip() for t in args.tfs.split(",")]
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, t, args.rows): (s, t)
for s in syms for t in tfs}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
s, t = fut[f]
print(f" [{i}/{len(fut)}] {s} {t} "
f"{0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d)
if __name__ == "__main__":
main()
+271
View File
@@ -0,0 +1,271 @@
"""怎么把「趋势末端的一类」从「趋势中途的一类」里挑出来。
§3.397 的关键数字一类里命中线段顶点真反转的只有 30% 30% 即使带着
8~9 根滞后也有 PF 0.70~0.83**没命中的 70% PF 0.08**
所以亏损几乎全部来自被误识别在趋势中途的那批 用户的判断
于是问题变成有没有**实时可算**的特征能把两批分开
**首要候选来自缠论本身**一类买点要求的是**趋势背驰**而趋势的定义是
至少两个同向连续的中枢引擎的 `find_all_bsp` **任意**中枢都发信号
完全没查这个前提 单个盘整中枢上的背驰只是盘整背驰本就不该当一类用
`fast_bsp.add_zone_ladder` 早就实现了这个判定B4 上把 PF 2.72 提到 3.41
一类这边却没接
测的特征全部只用信号时刻及之前的数据
ladder 本中枢相对前一中枢是否同向推进下降趋势要求 zg < 前一个 zd
zs_count 该中枢在本段里的序号越大趋势越成熟
div 离开段 MACD 面积 / 进入段面积越小背驰越强
ext_run 极值越过中枢边界多少个 ATR越大越延伸
atr_z 极值处波动率§3.398
评判分两层**能否提高命中线段顶点的概率**检测器精度
以及**能否提高实际收益**可交易性前者好后者不好也没用
"""
from __future__ import annotations
import argparse
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step62_trend_end.feather"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
BASE_WIN, TOL = 200, 2
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
from lib.data import fetch_ohlcv
from lib.exit_model import cfg_name, walk_exits
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
if df is None or len(df) < 5_000:
return None
chan = TF_DF(df, 1, tf, lean=False)
cdf = chan.dataframe
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
if not bz:
return None
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
dser = pd.to_datetime(cdf["date"])
if dser.dt.tz is not None:
dser = dser.dt.tz_localize(None)
didx = pd.DatetimeIndex(dser)
n = len(cdf)
def to_i(ts) -> int:
t = pd.Timestamp(ts)
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
atr = cdf["atr"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
.median().shift(1).to_numpy())
# 标准答案:线段终点(未来函数,只当标签用,不进入任何过滤器)
seg_bot, seg_top = [], []
for sg in getattr(chan, "seg_list", []) or []:
if sg.end_time is None:
continue
i = to_i(sg.end_time)
if 0 <= i < n:
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN
else seg_top).append(i)
if not seg_bot or not seg_top:
return None
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
def near(i: int, d: int) -> bool:
a = truth[d]
k = int(np.searchsorted(a, i))
return any(0 <= j < len(a) and abs(int(a[j]) - i) <= TOL
for j in (k - 1, k))
# 中枢阶梯:按可用顺序排好,才谈得上「相对前一个」
zs_seq = sorted(bz, key=lambda z: to_i(z.bi_list[0].start_time))
pos = {id(z): k for k, z in enumerate(zs_seq)}
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1)}
rec = []
for b in bsp:
tag = want.get(b.type)
if tag is None or b.sure_time is None or b.zs is None:
continue
name, d = tag
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
if not (0 <= i_ext < n and 0 <= i_sure < n):
continue
a = atr[i_ext]
if not np.isfinite(a) or a <= 0 or not np.isfinite(base[i_ext]):
continue
zs = b.zs
k = pos.get(id(zs))
# 趋势成熟度:本中枢往前数,连续同向推进的中枢有几个。
# 单看「相对前一个是否同向」没有区分力 —— B1 要求
# enter_bi.dir == leave_bi.dir == DOWN,即中枢向下进、向下出,
# 这本身就定义了它嵌在下跌趋势里,连续纯中枢自然逐级下移,
# 实测该条件在 2504 笔上恒为 True。**引擎已隐含强制了「趋势」前提。**
# 有区分力的是「连了几级」,那才是趋势成熟度。
ladder_n = 0
if k is not None:
j = k
while j > 0:
cur, prv = zs_seq[j], zs_seq[j - 1]
ok = (float(cur.zg) < float(prv.zd) if d == 1
else float(cur.zd) > float(prv.zg))
if not ok:
break
ladder_n += 1
j -= 1
enter_bi = zs.bi_list[0].pre if zs.bi_list else None
ea = abs(float(enter_bi.macd_hist)) if enter_bi is not None else np.nan
la = abs(float(b.bi.macd_hist))
edge = float(zs.zd) if d == 1 else float(zs.zg)
ext = float(b.klc.low if d == 1 else b.klc.high)
rec.append({
"sym": sym, "tf": tf, "type": name, "dir": d,
"i_ext": i_ext, "i_sure": i_sure,
"lag_bars": i_sure - i_ext,
"hit": near(i_ext, d),
"ladder_n": int(ladder_n),
"div": la / ea if (ea and np.isfinite(ea) and ea > 0) else np.nan,
"ext_run": abs(ext - edge) / a,
"atr_z": a / base[i_ext],
})
if not rec:
return None
r = pd.DataFrame(rec)
r = r[(r.i_sure < n - 2) & np.isfinite(atr[r.i_sure.values])
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
if r.empty:
return None
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
res = walk_exits(cdf, pd.DataFrame({
"entry_idx": r.i_sure.values, "direction": r.dir.values}),
[SL], [RUNNER], [MAXB], scale_at=SCALE_AT, runners=(RUNNER,),
runner_stops=(RSTOP,))
if len(res) != len(r):
return None
for c in ("g", "r", "c", "b"):
r[c] = res[f"{cfg}_{c}"].to_numpy()
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
return r
except Exception as e: # noqa: BLE001
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
return None
def perf(g: pd.DataFrame) -> dict:
from lib.exit_model import fee_of, taker_notional
net = g.g.values - fee_of(g.r.values, g.c.values)
gR = g.g.values / (SL * g.atr_pct.values)
tn = taker_notional(g.r.values, g.c.values)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
return {
"笔数": len(g), "命中率": f"{g.hit.mean()*100:.1f}%",
"胜率": f"{(net > 0).mean()*100:.1f}%",
"毛R": round(gR.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
}
def report(d: pd.DataFrame) -> None:
for tf, x in d.groupby("tf"):
print("\n" + "#" * 96)
print(f"########## {tf} · {len(x)} 笔一类 ##########")
print("\n【一】单特征对「命中线段顶点」的区分力(命中率基准 "
f"{x.hit.mean()*100:.1f}%")
rows = []
for nm, col, qs in [("背驰div", "div", 4), ("延伸ext_run", "ext_run", 4),
("波动atr_z", "atr_z", 4), ("趋势级数ladder_n", "", 0)]:
if not col:
q = pd.cut(x["ladder_n"], [-1, 1, 2, 3, 999],
labels=["级数≤1", "=2", "=3", "≥4"])
for k, g in x.groupby(q, observed=True):
if len(g) >= 30:
rows.append({"分组": str(k), **perf(g)})
continue
y = x.dropna(subset=[col])
if len(y) < 100:
continue
q = pd.qcut(y[col], qs,
labels=[f"{nm}Q{i+1}" for i in range(qs)],
duplicates="drop")
for k, g in y.groupby(q, observed=True):
if len(g) >= 30:
rows.append({"分组": str(k), **perf(g)})
print(pd.DataFrame(rows).to_string(index=False))
print("\n【二】叠加过滤:延伸是唯一单调的特征,看叠加还能不能推上去")
rows = [{"过滤器": "无(现状)", **perf(x)}]
e75 = x["ext_run"].quantile(.75)
e50 = x["ext_run"].quantile(.50)
for nm, g in [
(f"ext_run≥P50({e50:.1f})", x[x["ext_run"] >= e50]),
(f"ext_run≥P75({e75:.1f})", x[x["ext_run"] >= e75]),
(f"ext_run≥P75 且 级数≥3",
x[(x["ext_run"] >= e75) & (x["ladder_n"] >= 3)]),
(f"ext_run≥P75 且 div≥中位",
x[(x["ext_run"] >= e75) & (x["div"] >= x["div"].median())]),
]:
if len(g) >= 30:
rows.append({"过滤器": nm, **perf(g)})
print(pd.DataFrame(rows).to_string(index=False))
print("\n判读:命中率若被显著抬高,说明特征确实在区分「趋势末端 vs 中途」。"
"\n但 PF 才是能不能做的判据 —— 命中率上去而 PF 不过 1,"
"说明滞后仍然吃掉了全部。")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--tfs", default="5m,15m")
ap.add_argument("--rows", type=int, default=200_000)
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
report(pd.read_feather(OUT))
return
syms = [s.strip() for s in args.symbols.split(",")]
tfs = [t.strip() for t in args.tfs.split(",")]
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, t, args.rows): (s, t)
for s in syms for t in tfs}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
s, t = fut[f]
print(f" [{i}/{len(fut)}] {s} {t} "
f"{0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d)
if __name__ == "__main__":
main()
+246
View File
@@ -0,0 +1,246 @@
"""B4 主线的因果回放:实盘信号的时点是不是干净的。
step59 在一类上抓到一个会骗人的坑`sure_time` 是引擎**事后**标注的确认时刻
不等于可执行时刻用它当 entry 的回测 PF 虚高一倍以上B4 正在跑真钱
必须过同一关
**先验比一类好但方向要说清**§5.41 的代码审计
一类 `sure_time` 直接当**入场时刻** -> 早了就是虚高回测被高估
B4 `available_ts` 只是**扫描起点** -> 晚了只会漏信号回测偏保守
§5.41 实测全量的 `available_ts` 系统性**更晚**`bis[-1]` 取的是中枢结束而非
形成中位晚 62 分钟所以预期是回测保守而非回测虚高但那是 300
时点抽样 + 代码审计不是逐根验证而且留了个未知
**实盘会产出更多更早的信号那部分的质量不在回测统计里**
本脚本逐根重放同时量两边
准时率 全量信号在其 entry_idx 当根就能算出来的比例
迟到 首现晚于 entry_idx 实盘只能在更差的价位追
额外信号 回放发得出全量却没有的 §5.41 预言存在但没人统计过它们赚不赚
性能每根扫全部中枢跑不完一个中枢只能在其 available_ts 之后 scan 根内
出信号所以每根只需把窗口内的中枢喂给 `find_fast_bsp3`这是等价裁剪
不改变结果
"""
from __future__ import annotations
import argparse
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step63_b4_replay.feather"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
SCAN = 200
def replay(sym: str, tf: str, rows: int, warm: int,
steps: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
ensure_timestamp,
find_fast_bsp3,
zones_from_zs_list,
)
from lib.data import fetch_ohlcv
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
if df is None or len(df) < warm + steps + 100:
return None
df = df.iloc[-(warm + steps):].reset_index(drop=True)
# ---- 全量口径:回测就是这么算的 ----
full = TF_DF(df, 1, tf)
cdf = ensure_timestamp(full.dataframe)
zs_full = full.cal_bi_zs_list_pure(full.bi_list)
if not zs_full:
return None
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zs_full, cdf))
full_keys = {(int(r.entry_idx), int(r.direction))
for r in sig_full.itertuples()} if not sig_full.empty \
else set()
# ---- 回放口径:逐根重算 zones 再扫 ----
chan = TF_DF(df.iloc[:warm].copy(), 1, tf)
chan.init_stream(df.iloc[:warm].copy(), 1, tf)
first_seen: dict[tuple, int] = {}
for i in range(warm, len(df)):
chan.append_bar(df.iloc[i])
try:
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
if not zl:
continue
sub = ensure_timestamp(chan.dataframe)
z = zones_from_zs_list(zl, sub)
if z.empty:
continue
# 等价裁剪:available_ts 早于 scan 根之前的中枢,其扫描窗口
# 已经过去,不可能在本根产出新信号
lo = sub["timestamp"].to_numpy()[max(0, len(sub) - SCAN - 2)]
z = z[z.available_ts >= lo]
if z.empty:
continue
s = find_fast_bsp3(sub, z, scan=SCAN)
except Exception: # noqa: BLE001
continue
if s is None or s.empty:
continue
for r in s.itertuples():
k = (int(r.entry_idx), int(r.direction))
if k not in first_seen:
first_seen[k] = i
rec = []
for k in set(full_keys) | set(first_seen):
e, d = k
if e < warm: # 预热段不计入
continue
seen = first_seen.get(k)
rec.append({
"sym": sym, "tf": tf, "entry_idx": e, "direction": d,
"in_full": k in full_keys, "in_replay": seen is not None,
"i_seen": -1 if seen is None else seen,
"late": (np.nan if seen is None else seen - e),
})
if not rec:
return None
r = pd.DataFrame(rec)
# 实盘真正会做的:首现根入场(首现==entry_idx 即准时)
from lib.exit_model import cfg_name, walk_exits
atr = cdf["atr"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
n = len(cdf)
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
live = r[r.in_replay & (r.i_seen < n - 2)].copy()
live = live[np.isfinite(atr[live.i_seen.values])
& (atr[live.i_seen.values] > 0)]
if not live.empty:
res = walk_exits(cdf, pd.DataFrame({
"entry_idx": live.i_seen.values,
"direction": live.direction.values}), [SL], [RUNNER], [MAXB],
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
if len(res) == len(live):
for c in ("g", "r", "c"):
live[c] = res[f"{cfg}_{c}"].to_numpy()
live["atr_pct"] = (atr[live.i_seen.values]
/ cl[live.i_seen.values])
r = r.merge(live[["entry_idx", "direction", "g", "r", "c",
"atr_pct"]],
on=["entry_idx", "direction"], how="left")
return r
except Exception as e: # noqa: BLE001
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
return None
def perf(g: pd.DataFrame) -> dict:
from lib.exit_model import fee_of, taker_notional
net = g.g.values - fee_of(g.r.values, g.c.values)
gR = g.g.values / (SL * g.atr_pct.values)
R = net / (SL * g.atr_pct.values)
tn = taker_notional(g.r.values, g.c.values)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
return {
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
}
def report(d: pd.DataFrame) -> None:
print("\n" + "=" * 92)
print("########## 一、准时率与额外信号 ##########")
rows = []
for tf, x in d.groupby("tf"):
both = x[x.in_full & x.in_replay]
rows.append({
"tf": tf,
"全量信号": int(x.in_full.sum()),
"回放信号": int(x.in_replay.sum()),
"召回": f"{len(both)/max(int(x.in_full.sum()),1)*100:.1f}%",
"准时(首现==entry)": f"{(both.late == 0).mean()*100:.1f}%",
"迟到中位": (f"{both.late[both.late > 0].median():.0f}"
if (both.late > 0).any() else ""),
"额外信号": int((x.in_replay & ~x.in_full).sum()),
})
print(pd.DataFrame(rows).to_string(index=False))
print("\n额外信号 = 回放发得出、全量没有的。§5.41 预言它们存在"
"(实盘 available_ts 更早 -> 信号更多更早),本表给出数量。")
if "g" not in d.columns:
return
print("\n" + "=" * 92)
print("########## 二、分组收益:回测口径 vs 实盘口径 ##########")
for tf, x in d.groupby("tf"):
y = x.dropna(subset=["g"])
if len(y) < 30:
continue
print(f"\n--- {tf} ---")
rows = []
for nm, g in [
("全部回放信号(=实盘会做的)", y[y.in_replay]),
("其中 准时的", y[y.in_replay & (y.late == 0)]),
("其中 迟到的", y[y.in_replay & (y.late > 0)]),
("其中 额外的(全量没有)", y[y.in_replay & ~y.in_full]),
("回测口径(全量∩回放)", y[y.in_full & y.in_replay]),
]:
if len(g) >= 30:
rows.append({"分组": nm, **perf(g)})
print(pd.DataFrame(rows).to_string(index=False))
print("\n判读:若「全部回放信号」的 PF 不低于「回测口径」,说明 B4 的时点"
"是干净的,\n且 §5.41 说的『回测偏保守』成立 —— 实盘拿到的反而更多。"
"\n若额外信号那组显著更差,那就是回测没统计到的隐性成本。")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--tf", default="5m")
ap.add_argument("--rows", type=int, default=45_000)
ap.add_argument("--warm", type=int, default=20_000)
ap.add_argument("--steps", type=int, default=20_000)
ap.add_argument("--workers", type=int, default=5)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
report(pd.read_feather(OUT))
return
syms = [s.strip() for s in args.symbols.split(",")]
print(f"[B4 因果回放] {len(syms)}× {args.steps} 根逐根重放\n", flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(replay, s, args.tf, args.rows, args.warm,
args.steps): s for s in syms}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
print(f" [{i}/{len(syms)}] {fut[f]} "
f"{0 if r is None else len(r)} 个信号", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d)
if __name__ == "__main__":
main()
+257
View File
@@ -0,0 +1,257 @@
"""二类买卖点:它只是一类的延迟版,还是一类里被确认过的那个子集?
用户提出的悖论二类按定义是一买之后回调再继续趋势可一类已经证否了
二类凭什么会好
代码上悖论成立`find_all_bsp` B2 确实被 B1 门控`if first_bsp_bi_div`
B2 B1 多要求两件事价格**确实反弹了**bounce_bi 向上
且回踩**守住了** B1 的低点`second_bsp_bi.end_klc.low > leave_bi.end_klc.low`
**这两条正是那个底是真的的事后确认**
§3.397 的诊断恰恰是一类只有 30% 落在真反转上 30% PF 0.70~0.83
另外 70% 0.08所以按确认筛掉假底正是一类缺的东西
于是悖论变成一个可测的问题
B2 命中线段顶点的比例是否显著高于 B1 30%
-> B2 B1 **已验证子集**悖论解除值得继续查
-> B2 只是 B1 的延迟版用户的悖论成立直接关掉
同时量三件与一类可比的东西口径完全对齐才能横向比
几何 入场价到结构止损位有多远一类是 2.90 ATR止损落在结构内侧 88.6%
波动 atr_z一类 1.22 偏高二类 0.94 偏低画像相反
反手 一类反手在全量口径上曾看似很好二类是否也有这个现象
"""
from __future__ import annotations
import argparse
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step64_b2.feather"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
BASE_WIN, TOL = 200, 2
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
from lib.data import fetch_ohlcv
from lib.exit_model import cfg_name, walk_exits
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
if df is None or len(df) < 5_000:
return None
chan = TF_DF(df, 1, tf, lean=False)
cdf = chan.dataframe
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
if not bz:
return None
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
dser = pd.to_datetime(cdf["date"])
if dser.dt.tz is not None:
dser = dser.dt.tz_localize(None)
didx = pd.DatetimeIndex(dser)
n = len(cdf)
def to_i(ts) -> int:
t = pd.Timestamp(ts)
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
atr = cdf["atr"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
op = cdf["open"].to_numpy(float)
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
.median().shift(1).to_numpy())
seg_bot, seg_top = [], []
for sg in getattr(chan, "seg_list", []) or []:
if sg.end_time is None:
continue
i = to_i(sg.end_time)
if 0 <= i < n:
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN
else seg_top).append(i)
if not seg_bot or not seg_top:
return None
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
def near(i: int, d: int) -> bool:
a = truth[d]
k = int(np.searchsorted(a, i))
return any(0 <= j < len(a) and abs(int(a[j]) - i) <= TOL
for j in (k - 1, k))
# 一类按中枢建索引,好给二类找到它自己那个一类的低点 ——
# 二类的天然止损位是**一类的极值**,不是它自己的极值
one_ext: dict[int, float] = {}
for b in bsp:
if b.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1) and b.zs:
d = 1 if b.type == Chan_BSP_TYPE.B1 else -1
one_ext[id(b.zs)] = float(b.klc.low if d == 1 else b.klc.high)
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1)}
rec = []
for b in bsp:
tag = want.get(b.type)
if tag is None or b.sure_time is None:
continue
name, d = tag
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
if not (0 <= i_ext < n and 0 <= i_sure < n):
continue
a = atr[i_ext]
if not np.isfinite(a) or a <= 0 or not np.isfinite(base[i_ext]):
continue
own = float(b.klc.low if d == 1 else b.klc.high)
# 二类的结构止损用一类的极值(回踩不破的就是那个点);
# 一类用自己的极值。这样两者的「入场离结构位多远」才可比
struct = own
if name in ("B2", "S2") and b.zs is not None:
struct = one_ext.get(id(b.zs), own)
rec.append({
"sym": sym, "tf": tf, "type": name, "dir": d,
"i_ext": i_ext, "i_sure": i_sure,
"lag_bars": i_sure - i_ext,
"hit": near(i_ext, d),
"atr_z": a / base[i_ext],
"own_ext": own, "struct_px": struct,
"entry_px": op[min(i_sure + 1, n - 1)],
})
if not rec:
return None
r = pd.DataFrame(rec)
r = r[(r.i_sure < n - 2) & np.isfinite(atr[r.i_sure.values])
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
if r.empty:
return None
r["atr_at_entry"] = atr[r.i_sure.values]
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
r["to_struct"] = ((r.entry_px - r.struct_px) * r.dir
/ r.atr_at_entry)
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
for sfx, sgn in (("", 1), ("f_", -1)):
res = walk_exits(cdf, pd.DataFrame({
"entry_idx": r.i_sure.values,
"direction": sgn * r.dir.values}), [SL], [RUNNER], [MAXB],
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
if len(res) != len(r):
return None
for c in ("g", "r", "c", "b"):
r[sfx + c] = res[f"{cfg}_{c}"].to_numpy()
return r
except Exception as e: # noqa: BLE001
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
return None
def perf(g: pd.DataFrame, pre: str = "") -> dict:
from lib.exit_model import fee_of, taker_notional
gg, rr, cc = g[pre + "g"].values, g[pre + "r"].values, g[pre + "c"].values
net = gg - fee_of(rr, cc)
gR = gg / (SL * g.atr_pct.values)
tn = taker_notional(rr, cc)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
return {
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
"毛R": round(gR.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
}
def report(d: pd.DataFrame) -> None:
for tf, x in d.groupby("tf"):
print("\n" + "#" * 96)
print(f"########## {tf} ##########")
print("\n【一】悖论的判据:二类命中线段顶点的比例是否高于一类")
rows = []
for t in ["B1", "S1", "B2", "S2"]:
g = x[x.type == t]
if len(g) < 30:
continue
rows.append({
"类型": t, "样本": len(g),
"命中线段顶点": f"{g.hit.mean()*100:.1f}%",
"atr_z中位": round(g.atr_z.median(), 3),
"滞后中位": int(g.lag_bars.median()),
"入场到结构位(ATR)": round(g.to_struct.median(), 2),
"止损2ATR落在结构内侧": f"{(g.to_struct > 2).mean()*100:.0f}%",
})
print(pd.DataFrame(rows).to_string(index=False))
print("\n 「入场到结构位」是入场价离天然止损位多少个 ATR。二类的结构位取"
"\n 它那个一类的极值(回踩不破的就是那点)。>2 表示 2ATR 的止损挂在"
"\n 结构位以内,价格不用回踩到前低就出局 —— 一类实测 88.6%")
print("\n【二】按原方向做(抄底/摸顶)")
print(pd.DataFrame([{"类型": t, **perf(g)}
for t, g in x.groupby("type")
if len(g) >= 30]).to_string(index=False))
print("\n【三】反手做")
print(pd.DataFrame([{"类型": t, **perf(g, "f_")}
for t, g in x.groupby("type")
if len(g) >= 30]).to_string(index=False))
print("\n【四】二类拆命中/未命中 —— 一类的对应数字是 0.08 vs 0.70")
two = x[x.type.isin(["B2", "S2"])]
if len(two) >= 60:
print(pd.DataFrame([
{"命中线段顶点": "" if k else "", **perf(g)}
for k, g in two.groupby(two.hit) if len(g) >= 20
]).to_string(index=False))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--tfs", default="5m,15m")
ap.add_argument("--rows", type=int, default=200_000)
ap.add_argument("--workers", type=int, default=2)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
report(pd.read_feather(OUT))
return
syms = [s.strip() for s in args.symbols.split(",")]
tfs = [t.strip() for t in args.tfs.split(",")]
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, t, args.rows): (s, t)
for s in syms for t in tfs}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
s, t = fut[f]
print(f" [{i}/{len(fut)}] {s} {t} "
f"{0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d)
if __name__ == "__main__":
main()
+180
View File
@@ -0,0 +1,180 @@
"""改识别规则之前,先量机会本身装不装得下确认成本。
用户问 B1/B2 的识别规则行不行
改识别规则有两种含义第一种已经被测过上限
(a) 换规则****出更好的 B1 step60 用未来函数只留极值落在真线段底 ±2 根内
的信号这是任何识别规则的理论最好情况PF 0.70~0.83这一类改法封死
0.83 这个上限本身是**结果**背后是一个从没直接测过的量
一段线段从真底走到真顶一共有几个 ATR而等笔确认要花掉 2.9
这决定了 (b) 类改法**入场时点/构造**而非改选样有没有空间
线段幅度 4 ATR -> 进场吃掉 2.9 1.1 去扛 2 ATR 止损**任何规则都救不活**
线段幅度 12 ATR -> 2.9 只是小费值得继续改规则
注意这测的是**市场**不是我们的检测器标准答案直接取 `seg_list` 的真实端点
完全不经过 `find_all_bsp`所以结论对换任何一套识别规则都成立
而且这个比值大概率**随级别变化**ATR 与线段幅度未必同比例缩放所以跑
5m/15m/1h/4h真正的产出不是改不改规则而是**在哪个级别上改才有意义**
预算恒等式每根线段一行
可捕获 = 线段幅度 确认成本 需要 > 止损 才有正期望的可能
"""
from __future__ import annotations
import argparse
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step65_room.feather"
SL_ATR = 2.0
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.core.ChanEnum import Chan_SEG_DIR
from lib.data import fetch_ohlcv
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
if df is None or len(df) < 3_000:
return None
chan = TF_DF(df, 1, tf, lean=False)
cdf = chan.dataframe
segs = [s for s in (getattr(chan, "seg_list", []) or [])
if s.end_time is not None and s.start_time is not None]
if len(segs) < 20:
return None
dser = pd.to_datetime(cdf["date"])
if dser.dt.tz is not None:
dser = dser.dt.tz_localize(None)
didx = pd.DatetimeIndex(dser)
n = len(cdf)
atr = cdf["atr"].to_numpy(float)
hi, lo = cdf["high"].to_numpy(float), cdf["low"].to_numpy(float)
def to_i(ts) -> int:
t = pd.Timestamp(ts)
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
rec = []
for sg in segs:
i0, i1 = to_i(sg.start_time), to_i(sg.end_time)
if not (0 <= i0 < i1 < n) or i1 - i0 < 2:
continue
a = atr[i0]
if not np.isfinite(a) or a <= 0:
continue
up = sg.dir != Chan_SEG_DIR.DOWN
# 从线段起点(上一段的真实反转点)到终点的幅度
span = ((hi[i0:i1 + 1].max() - lo[i0]) if up
else (hi[i0] - lo[i0:i1 + 1].min()))
rec.append({
"sym": sym, "tf": tf, "dir": 1 if up else -1,
"bars": i1 - i0,
"span_atr": span / a,
"atr_pct": a / float(cdf["close"].to_numpy(float)[i0]),
})
return pd.DataFrame(rec) if rec else None
except Exception as e: # noqa: BLE001
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
return None
TFO = {"5m": 0, "15m": 1, "1h": 2, "4h": 3}
def report(d: pd.DataFrame, cost: float) -> None:
print("\n" + "=" * 96)
print(f"【一】线段幅度 vs 确认成本(成本按一类实测的 {cost} ATR 计)")
print("=" * 96)
rows = []
for tf, x in sorted(d.groupby("tf"), key=lambda kv: TFO.get(kv[0], 9)):
q = x.span_atr.quantile([.25, .5, .75]).values
room = x.span_atr - cost
rows.append({
"级别": tf, "线段数": len(x),
"幅度Q1": round(q[0], 1), "幅度中位": round(q[1], 1),
"幅度Q3": round(q[2], 1),
"确认后剩余(中位)": round(q[1] - cost, 1),
f"剩余>止损{SL_ATR}": f"{(room > SL_ATR).mean()*100:.0f}%",
"剩余/止损": round((q[1] - cost) / SL_ATR, 2),
"中位时长(根)": int(x.bars.median()),
})
print(pd.DataFrame(rows).to_string(index=False))
print(f"""
剩余/止损是这张表的结论行真底进场扣掉确认成本后还剩几倍止损的空间
< 1 机会装不下确认成本**换任何识别规则都没用**
1~2 勉强打平要求选样精度极高step60 实测最好 30%
> 2 有空间值得改规则/改入场构造""")
print("\n【二】把确认成本当变量:多低才够用")
rows = []
for tf, x in sorted(d.groupby("tf"), key=lambda kv: TFO.get(kv[0], 9)):
r = {"级别": tf}
for c in (0.0, 1.0, 2.0, 2.9):
r[f"成本{c}"] = round((x.span_atr.median() - c) / SL_ATR, 2)
rows.append(r)
print(pd.DataFrame(rows).to_string(index=False))
print("\n 成本 0 = 完美实时(在真底那根就进)。若连成本 0 那列都 < 2,"
"\n 说明**不是滞后的问题,是这个级别的线段本身就太小**。")
print("\n【三】逐币(中位幅度 ATR),看结论是否普适")
print(d.pivot_table(index="sym", columns="tf", values="span_atr",
aggfunc="median").round(1)
.reindex(columns=[t for t in TFO if t in set(d.tf)])
.to_string())
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--tfs", default="5m,15m,1h,4h")
ap.add_argument("--rows", type=int, default=200_000)
ap.add_argument("--cost", type=float, default=2.9)
ap.add_argument("--workers", type=int, default=2)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
report(pd.read_feather(OUT), args.cost)
return
syms = [s.strip() for s in args.symbols.split(",")]
tfs = [t.strip() for t in args.tfs.split(",")]
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, t, args.rows): (s, t)
for s in syms for t in tfs}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
s, t = fut[f]
print(f" [{i}/{len(fut)}] {s} {t} "
f"{0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d, args.cost)
if __name__ == "__main__":
main()
+152
View File
@@ -0,0 +1,152 @@
"""改识别规则到底有没有用:把天花板一次性测到顶。
用户问 B1/B2 的识别规则呢step65 让这个问题变得可判定但也制造了一个
必须解释的矛盾
线段中位 11.4 ATR进场花掉 2.9 8.5 2 ATR 止损的 4.26
step60 用未来函数选出真底的那批 B1PF 仍只有 0.70~0.83
空间明明在就是拿不到两种解释对用户的问题给出**相反**的答案
选样问题只有 30% B1 落在真线段底 8.5 ATR 只存在于这 30%
其余 70% 是趋势中途后面根本没有行情 -> **改识别规则有用**
路径问题就算落在真底11.4 ATR 是净幅度路上的回撤照样打掉止损
-> **改识别规则没用**
区分只需要一个从没跑过的组合**未来函数选样 + 结构止损 + 绝对目标**
三样单独都失败过step60 0.83 / step58 0.36 / 本轮 abs 0.36
组合起来没试过它测的不是某条规则**所有识别规则的上界**
上界 < 1.0 -> 路径问题一类线彻底关闭别再改规则
上界 > 1.5 -> 选样问题改识别规则有用且这里就是要够到的目标
同时量 MFE 做交叉验证真底那批到底跑没跑出 8.5 ATR
跑出来了却还是亏 -> 铁证是止损/路径
根本没跑出来 -> 说明 11.4 ATR 那个测量传导不到这批信号上step65 要重解释
"""
from __future__ import annotations
import argparse
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
SRC = HERE / "out" / "step64_b2.feather"
MAXB = 48
def mfe_mae(cdf: pd.DataFrame, idx: np.ndarray, dirs: np.ndarray,
atr: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""入场后 MAXB 根内最远的顺向/逆向行程(ATR)。"""
hi, lo = cdf["high"].to_numpy(float), cdf["low"].to_numpy(float)
op = cdf["open"].to_numpy(float)
n = len(cdf)
fe = np.full(len(idx), np.nan)
ae = np.full(len(idx), np.nan)
for k, (i, d, a) in enumerate(zip(idx, dirs, atr)):
e = int(i) + 1
if e >= n or not np.isfinite(a) or a <= 0:
continue
j = min(e + MAXB, n)
px = op[e]
if d == 1:
fe[k] = (hi[e:j].max() - px) / a
ae[k] = (px - lo[e:j].min()) / a
else:
fe[k] = (px - lo[e:j].min()) / a
ae[k] = (hi[e:j].max() - px) / a
return fe, ae
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--tf", default="5m")
ap.add_argument("--rows", type=int, default=200_000)
args = ap.parse_args()
from chanlun import TF_DF
from lib.data import fetch_ohlcv
from step58_struct_stop import simulate, stats
d = pd.read_feather(SRC)
d = d[(d.tf == args.tf) & d.type.isin(["B1", "S1"])].copy()
d["to_ext"] = (d.entry_px - d.own_ext) * d.dir / d.atr_at_entry
print(f"[识别规则天花板] {args.tf} · 一类 {len(d)} 笔 · "
f"其中落在真线段端点 {d.hit.sum()} 笔 ({d.hit.mean()*100:.1f}%)\n")
cache = {}
for sym in sorted(d.sym.unique()):
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
cache[sym] = TF_DF(df, 1, args.tf).dataframe
print("=" * 96)
print("【一】MFE 交叉验证:真底那批到底跑没跑出 step65 说的 8.5 ATR")
print("=" * 96)
rows = []
for k, g in d.groupby(d.hit):
fe, ae = [], []
for sym, x in g.groupby("sym"):
f_, a_ = mfe_mae(cache[sym], x.i_sure.values, x.dir.values,
x.atr_at_entry.values)
fe.append(f_)
ae.append(a_)
fe, ae = np.concatenate(fe), np.concatenate(ae)
ok = np.isfinite(fe)
rows.append({
"落在真线段端点": "" if k else "", "笔数": int(ok.sum()),
"MFE中位": round(float(np.median(fe[ok])), 2),
"MFE≥3ATR": f"{(fe[ok] >= 3).mean()*100:.0f}%",
"MFE≥8ATR": f"{(fe[ok] >= 8).mean()*100:.0f}%",
"MAE中位": round(float(np.median(ae[ok])), 2),
"MAE≥2ATR(会被现止损打掉)": f"{(ae[ok] >= 2).mean()*100:.0f}%",
})
print(pd.DataFrame(rows).to_string(index=False))
print("""
判读那行 MFE 中位远低于 8.5说明 step65 的线段幅度传导不到
信号上进场时行情已经走掉了大半 MFE 够大而 MAE 也大则是路径问题""")
print("\n" + "=" * 96)
print("【二】天花板:未来函数选样 + 结构止损 + 绝对目标(三者首次组合)")
print("=" * 96)
rows = []
for k, g in d.groupby(d.hit):
for label, margin in [("固定2ATR", None), ("结构+0.5", 0.5),
("结构+1.0", 1.0), ("结构+1.5", 1.5)]:
gs, rs, cs, ap_, sl_ = [], [], [], [], []
for sym, x in g.groupby("sym"):
x = x.reset_index(drop=True)
t = pd.DataFrame({"entry_idx": x.i_sure.values,
"direction": x.dir.values,
"atr_at_entry": x.atr_at_entry.values})
sl = (np.full(len(x), 2.0) if margin is None
else (x.to_ext.values + margin))
res = simulate(cache[sym], t, sl, r_scale=False)
ok = res.g.notna().values
gs.append(res.g[ok])
rs.append(res.r[ok])
cs.append(res.c[ok])
ap_.append((x.atr_at_entry.values / x.entry_px.values)[ok])
sl_.append(sl[ok])
rows.append({
"落在真线段端点": "" if k else "", "止损": label,
**stats(pd.concat(gs, ignore_index=True),
pd.concat(rs, ignore_index=True),
pd.concat(cs, ignore_index=True),
np.concatenate(ap_), np.concatenate(sl_))})
print(pd.DataFrame(rows).to_string(index=False))
print("""
那几行就是**任何识别规则的上界**因为选样已经用了未来函数
< 1.0 路径问题一类线关闭改规则无解
> 1.5 选样问题改识别规则有用且这就是要够到的目标""")
if __name__ == "__main__":
main()
+195
View File
@@ -0,0 +1,195 @@
"""把 step66 的天花板换成实时可算的选样,看能兑现多少。
step66 测出上界**未来函数选样 + 结构止损** 5m PF 2.2915m 4.11
但那个选样用了线段端点事后才知道不能交易本脚本把它换成 step62 那个
**实时可算** `ext_run`极值越过中枢边界几个 ATR当根即可算配上结构止损
这是唯一同时处理两个已知约束的组合也是 §3.3992 列出的第一个待跑实验
step62 有实时过滤器精度 28.7% -> 47.9%但配了 2 ATR 止损 -> PF 0.54
step58 有结构止损但没有过滤器 -> PF 0.36
step66 两个都有但选样是未来函数 -> PF 2.29 / 4.11
**本脚本实时过滤器 + 结构止损** -> ?
判读对照 step66 的上界
> 1.3 兑现了相当部分一类线值得继续下一步做因果回放sure_time 仍是未来函数
~ 1.0 过滤器精度不够需要更好的实时特征
< 0.8 实时特征抓不到那 28.7%天花板兑现不了一类线仍关闭
即使 > 1.3 **不能算数**入场仍在 `sure_time` §3.396 证明实盘回放
还要再晚 15 这一步只决定值不值得再花一次因果回放的机器时间
"""
from __future__ import annotations
import argparse
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
F62 = HERE / "out" / "step62_trend_end.feather"
F64 = HERE / "out" / "step64_b2.feather"
KEY = ["sym", "tf", "type", "i_ext", "i_sure"]
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--tf", default="5m")
ap.add_argument("--rows", type=int, default=200_000)
args = ap.parse_args()
from chanlun import TF_DF
from lib.data import fetch_ohlcv
from step58_struct_stop import simulate, stats
a = pd.read_feather(F62)[KEY + ["ext_run", "div", "ladder_n"]]
b = pd.read_feather(F64)[KEY + ["entry_px", "own_ext",
"atr_at_entry", "hit", "dir"]]
d = a.merge(b, on=KEY, how="inner")
d = d[d.tf == args.tf].reset_index(drop=True)
d["to_ext"] = (d.entry_px - d.own_ext) * d.dir / d.atr_at_entry
print(f"[实时过滤器 + 结构止损] {args.tf} · 一类 {len(d)} 笔 · "
f"真线段端点占比 {d.hit.mean()*100:.1f}%")
print(f"step66 上界(同止损、但用未来函数选样):"
f"{'PF 2.29' if args.tf == '5m' else 'PF 4.11'}\n")
cache = {}
for sym in sorted(d.sym.unique()):
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
cache[sym] = TF_DF(df, 1, args.tf).dataframe
def run(x: pd.DataFrame, margin: float | None) -> dict:
gs, rs, cs, ap_, sl_ = [], [], [], [], []
for sym, g in x.groupby("sym"):
g = g.reset_index(drop=True)
t = pd.DataFrame({"entry_idx": g.i_sure.values,
"direction": g.dir.values,
"atr_at_entry": g.atr_at_entry.values})
sl = (np.full(len(g), 2.0) if margin is None
else (g.to_ext.values + margin))
res = simulate(cache[sym], t, sl, r_scale=False)
ok = res.g.notna().values
gs.append(res.g[ok])
rs.append(res.r[ok])
cs.append(res.c[ok])
ap_.append((g.atr_at_entry.values / g.entry_px.values)[ok])
sl_.append(sl[ok])
return stats(pd.concat(gs, ignore_index=True),
pd.concat(rs, ignore_index=True),
pd.concat(cs, ignore_index=True),
np.concatenate(ap_), np.concatenate(sl_))
print("=" * 96)
print("【一】ext_run 分档 × 止损口径 —— 过滤器的效果依赖止损吗")
print("=" * 96)
q = d.ext_run.quantile([.25, .5, .75]).values
lab = ["Q1最短", "Q2", "Q3", "Q4最延伸"]
d["bucket"] = pd.cut(d.ext_run, [-np.inf, *q, np.inf], labels=lab)
rows = []
for bk, g in d.groupby("bucket", observed=True):
for name, m in [("固定2ATR", None), ("结构+1.0", 1.0)]:
rows.append({"ext_run档": bk, "止损": name, "真端点占比":
f"{g.hit.mean()*100:.0f}%", **run(g, m)})
print(pd.DataFrame(rows).to_string(index=False))
print("""
§3.399 判过过滤器符号随入场时点翻转 -> 拟合这里换的是**止损**而非入场
Q4 在两种止损下都最好说明 ext_run 是真信号若又翻转则仍是拟合""")
print("\n" + "=" * 96)
print("【二】实时可交易组合 vs step66 上界")
print("=" * 96)
rows = [{"选样": "全体(无过滤)", "笔数": len(d), **run(d, 1.0)}]
for p in (50, 75):
thr = np.percentile(d.ext_run, p)
g = d[d.ext_run >= thr]
rows.append({"选样": f"ext_run ≥ P{p}(实时)", "笔数": len(g),
**run(g, 1.0)})
g = d[(d.ext_run >= np.percentile(d.ext_run, 75))
& (d["div"] >= d["div"].median())]
rows.append({"选样": "ext_run≥P75 且 div≥中位(实时)",
"笔数": len(g), **run(g, 1.0)})
gh = d[d.hit]
rows.append({"选样": "★真线段端点(未来函数=上界)", "笔数": len(gh),
**run(gh, 1.0)})
print(pd.DataFrame(rows).to_string(index=False))
print("""
判读实时行若接近 那行说明 ext_run 抓到了同一批信号一类线值得继续
若仍贴近全体说明实时特征抓不到那 28.7%天花板兑现不了
即便好也不算数入场仍在 sure_time 必须再过一次因果回放""")
print("\n" + "=" * 96)
print("【三】数字对不上,追一下:ext_run 挑出的真端点,质量还一样吗")
print("=" * 96)
print("Q4 的真端点浓度 44%(基线 28.7%),若真端点都值 PF 2.29"
"Q4 不该只有 0.51。\n拆开看 ext_run 在真端点**内部**是帮忙还是帮倒忙——"
"这决定天花板是否可学:")
rows = []
for k, g in d.groupby(d.hit):
for bk, gg in g.groupby("bucket", observed=True):
if len(gg) < 25:
continue
rows.append({"真端点": "" if k else "", "ext_run档": bk,
"笔数": len(gg), **run(gg, 1.0)})
print(pd.DataFrame(rows).to_string(index=False))
print("""
组内若 Q4 明显低于 Q1 -> ext_run 在真端点里**反向选样**
它提高浓度的同时挑走了最差的那些两个效应抵消这解释了 0.51 vs 2.29
组内各档持平 -> 浓度提升是真的缺的只是更强的实时特征""")
print("\n" + "=" * 96)
print("【四】那需要多高的实时精度才能翻正")
print("=" * 96)
print("【三】显示只有「是不是真端点」这一个变量在起作用(组内各档持平)。"
"\n于是 PF 只是两组按精度 p 的混合,可以直接解出盈亏平衡精度:")
net = {}
for k, g in d.groupby(d.hit):
ns = []
for sym, x in g.groupby("sym"):
x = x.reset_index(drop=True)
t = pd.DataFrame({"entry_idx": x.i_sure.values,
"direction": x.dir.values,
"atr_at_entry": x.atr_at_entry.values})
res = simulate(cache[sym], t, x.to_ext.values + 1.0,
r_scale=False)
ok = res.g.notna().values
from lib.exit_model import fee_of
ns.append(res.g[ok].values
- fee_of(res.r[ok].values, res.c[ok].values))
net[bool(k)] = np.concatenate(ns)
def mix_pf(p: float) -> float:
"""精度 p 时的 PF:两组按 p 加权(组内分布不变,只变权重)。"""
h, m = net[True], net[False]
w = (p * h[h > 0].sum() / len(h)
+ (1 - p) * m[m > 0].sum() / len(m))
l = (p * -h[h <= 0].sum() / len(h)
+ (1 - p) * -m[m <= 0].sum() / len(m))
return w / l if l > 0 else np.inf
grid = [0.287, 0.35, 0.44, 0.5, 0.6, 0.7, 0.8, 1.0]
print(pd.DataFrame([{
"实时精度": f"{p*100:.1f}%", "PF": round(mix_pf(p), 2),
"备注": {0.287: "← 当前基线", 0.44: "← ext_run Q4 已达到",
1.0: "← step66 上界"}.get(p, "")} for p in grid]
).to_string(index=False))
lo, hi = 0.287, 1.0
if mix_pf(hi) > 1 > mix_pf(lo):
for _ in range(40):
mid = (lo + hi) / 2
lo, hi = (mid, hi) if mix_pf(mid) < 1 else (lo, mid)
print(f"\n **盈亏平衡精度 ≈ {(lo+hi)/2*100:.1f}%** "
f"(当前 28.7%ext_run Q4 已到 44.0%")
print(" 这就是「改识别规则」要够到的具体门槛,且它只是毛平衡;"
"\n 还要再扣 §3.396 的因果滞后(实盘比 sure_time 再晚 15 根)。")
if __name__ == "__main__":
main()
+265
View File
@@ -0,0 +1,265 @@
"""按**实盘口径**重放 B4,并测三道过滤能否刷掉那批亏钱的额外信号。
step63 的结论是一好一坏
时点干净 100% 召回100% 准时零滞后一类是 0% 准时+15
回放多出 592 个全量口径没有的信号PF 0.46 / t 5.03混合后 0.64 < 1
step63 有两个口径问题本脚本一并修掉
窗口不对回测用全量 45000 根一次算完step63 2 万涨到 4 万根的增长窗口
**而实盘用 2001 根滚动窗口 500 init_stream 拉回**`shadow_signal.py`
MAX_GROW三种口径的中枢结构都不一样这也是 HANDOFF 里挂着的
回测用全量历史建中枢实盘用 2000 根窗口那条待办
顺带窗口封顶后单步成本恒定不再是 step63 那个平方级143ms@2万根 ->
292ms@4万根所以本脚本快得多
没测过滤step63 跑的是裸信号而实盘有三道滤网ATR 门控已单独测过
它刷掉 7.9% 的额外信号却刷掉 10.7% 的好信号PF 纹丝不动剩下两道要测
**一处刻意的简化方向是保守的**大级别分型时间线用全量历史算真实盘的 HTF
也会重画这等于**给同向过滤器开了未来函数的后门**若连这样都刷不掉额外信号
结论只会更强
"""
from __future__ import annotations
import argparse
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step68_live_window.feather"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
WIN, MAX_GROW, GATE_BP = 2001, 500, 8.0
def _ladder(zones: pd.DataFrame) -> pd.DataFrame:
z = zones.copy()
pg, pdn = z["zg"].shift(), z["zd"].shift()
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
z["zone_i"] = np.arange(len(z))
return z
def replay(sym: str, ltf: str, htf: str, rows: int,
steps: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
ensure_timestamp,
find_fast_bsp3,
zones_from_zs_list,
)
from lib.data import fetch_ohlcv
from lib.fx_signal import extract_fx_signals, signals_to_frame
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
if df is None or len(df) < WIN + steps + 100:
return None
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
full = TF_DF(df, 1, ltf)
cdf = ensure_timestamp(full.dataframe)
zs_full = full.cal_bi_zs_list_pure(full.bi_list)
if not zs_full:
return None
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zs_full, cdf))
full_keys = {(int(r.entry_idx), int(r.direction))
for r in sig_full.itertuples()} if not sig_full.empty \
else set()
# 大级别分型时间线:全量算(见模块 docstring 的「刻意简化」)
dh = fetch_ohlcv(f"{sym}/USDT:USDT", htf, rows)
tl = None
if dh is not None and len(dh) > 500:
ch = TF_DF(dh, 1, htf)
tl = htf_fx_timeline(
signals_to_frame(extract_fx_signals(ch, ch.dataframe)),
ch.dataframe)
rec: dict[tuple, dict] = {}
chan = None
anchor = 0
for i in range(WIN, len(df)):
# 实盘的窗口纪律:2001 根起,长过 MAX_GROW 就 init_stream 拉回
if chan is None or (i - anchor) >= MAX_GROW:
w = df.iloc[i - WIN + 1:i + 1].copy()
chan = TF_DF(w, 1, ltf)
chan.init_stream(w, 1, ltf)
anchor = i
else:
chan.append_bar(df.iloc[i])
try:
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
if not zl:
continue
sub = ensure_timestamp(chan.dataframe)
z = _ladder(zones_from_zs_list(zl, sub))
if z.empty:
continue
s = find_fast_bsp3(sub, z)
if s is None or s.empty:
continue
last = len(sub) - 1
s = s[s["entry_idx"].astype(int) == last] # 实盘只做当根
if s.empty:
continue
if "zone_i" in s.columns:
s = s.merge(z[["zone_i", "z_above", "z_below"]],
on="zone_i", how="left")
if tl is not None:
s = attach_htf_context(s, sub, tl, "h1")
except Exception: # noqa: BLE001
continue
for r in s.itertuples():
k = (i, int(r.direction))
if k in rec:
continue
push = getattr(r, "z_above" if r.direction == 1
else "z_below", None)
ag = getattr(r, "h1_agree", 0)
rec[k] = {
"sym": sym, "entry_idx": i, "direction": int(r.direction),
"in_full": (i, int(r.direction)) in full_keys,
"ladder_ok": int(bool(pd.notna(push) and bool(push))),
"h1_agree": int(ag) if pd.notna(ag) else 0,
}
if not rec:
return None
r = pd.DataFrame(list(rec.values()))
from lib.exit_model import cfg_name, walk_exits
atr = cdf["atr"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
r = r[(r.entry_idx < len(cdf) - 2)
& np.isfinite(atr[r.entry_idx.values])
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
if r.empty:
return None
res = walk_exits(cdf, pd.DataFrame({
"entry_idx": r.entry_idx.values,
"direction": r.direction.values}), [SL], [RUNNER], [MAXB],
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
if len(res) != len(r):
return None
for c in ("g", "r", "c"):
r[c] = res[f"{cfg}_{c}"].to_numpy()
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
r["gate_ok"] = (r.atr_pct * 1e4 >= GATE_BP).astype(int)
r["pass_all"] = ((r.h1_agree == 1) & (r.ladder_ok == 1)
& (r.gate_ok == 1)).astype(int)
return r
except Exception as e: # noqa: BLE001
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
return None
def perf(g: pd.DataFrame) -> dict | None:
from lib.exit_model import fee_of, taker_notional
if len(g) < 20:
return None
net = g.g.values - fee_of(g.r.values, g.c.values)
gR = g.g.values / (SL * g.atr_pct.values)
tn = taker_notional(g.r.values, g.c.values)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
return {
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
}
def report(d: pd.DataFrame) -> None:
print("\n" + "=" * 92)
print("【一】实盘窗口下还有多少额外信号")
print("=" * 92)
print(f"回放信号 {len(d)} · 其中全量口径也有 {int(d.in_full.sum())} · "
f"**额外 {int((~d.in_full).sum())}**")
print(f"(step63 的增长窗口口径:197 / 592)")
print("\n" + "=" * 92)
print("【二】三道过滤能不能刷掉额外信号 —— 这决定实盘是否在亏钱")
print("=" * 92)
rows = []
for nm, m in [("① 无过滤", None), ("② 仅同向", d.h1_agree == 1),
("③ 仅阶梯", d.ladder_ok == 1),
("④ 仅ATR门控", d.gate_ok == 1),
("⑤ 三道全开(实盘口径)", d.pass_all == 1)]:
x = d if m is None else d[m]
if x.empty:
continue
ex, bt = x[~x.in_full], x[x.in_full]
row = {"过滤": nm, "留下": len(x),
"额外占比": f"{(~x.in_full).mean()*100:.0f}%"}
for lab, g in [("全部", x), ("额外", ex), ("回测口径", bt)]:
s = perf(g)
row[f"{lab}PF"] = "" if s is None else s["PF"]
row[f"{lab}n"] = len(g)
rows.append(row)
print(pd.DataFrame(rows).to_string(index=False))
print("\n" + "=" * 92)
print("【三】实盘口径(三道全开)的完整表现")
print("=" * 92)
rows = []
for nm, g in [("实盘会做的全部", d[d.pass_all == 1]),
(" 其中额外的", d[(d.pass_all == 1) & ~d.in_full]),
(" 其中回测也有的", d[(d.pass_all == 1) & d.in_full])]:
s = perf(g)
if s:
rows.append({"分组": nm, **s})
print(pd.DataFrame(rows).to_string(index=False))
print("""
判读实盘会做的全部PF > 1 -> 实盘安全额外信号被滤网挡住了
PF < 1 -> **实盘在做一批回测里不存在且亏钱的信号**要立刻处理""")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--ltf", default="5m")
ap.add_argument("--htf", default="30m")
ap.add_argument("--rows", type=int, default=45_000)
ap.add_argument("--steps", type=int, default=20_000)
ap.add_argument("--workers", type=int, default=5)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
report(pd.read_feather(OUT))
return
syms = [s.strip() for s in args.symbols.split(",")]
print(f"[实盘口径回放] {len(syms)}× {args.steps} 根 · "
f"{WIN} 根滚动窗口 / 每 {MAX_GROW} 根重建\n", flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(replay, s, args.ltf, args.htf, args.rows,
args.steps): s for s in syms}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
print(f" [{i}/{len(syms)}] {fut[f]} "
f"{0 if r is None else len(r)} 个信号", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d)
if __name__ == "__main__":
main()
+240
View File
@@ -0,0 +1,240 @@
"""那 72~77% 的「额外信号」到底怎么来的:中枢重画,还是扫描窗口错位?
我在 §3.3994 里把它归因为中枢重画**这个归因没验证过而且与已有结论矛盾**
step39 的假阳性 0%`verify_window_sens` 的窗口 +200/+500/+1000 逐字段一致
用户指出中枢不重画代码注释也支持他
# -1 = 最后一笔(历史默认)。中枢每吸收一根K线,最后一笔就可能后移,
# 于是 available_ts 跟着漂——这是右边缘重画的根因
chanlun/analysis/fast_bsp.py:47
漂的不是中枢**边界**zg/zd是它的**可用时刻** `find_fast_bsp3` 只从
`available_ts` 往后扫 `scan=200` 两种口径的窗口因此错位
实时 中枢没吸收完available_ts 偏早 -> 窗口开得早
全量 中枢吸收完了available_ts 偏晚§5.41 实测中位晚 62 分钟-> 窗口开得晚
落在实时窗口内全量窗口外的信号全量根本没扫到那个时段于是显示为额外
两种机制的修法完全不同所以必须分清
边界重画 结构本身不稳只能用滞后换稳定性代价大
窗口错位 中枢是同一个真中枢信号也是真信号只是**开得太早确认不足**
这正好解释它们为什么亏PF 0.26~0.36且修法是调 available_ts
判据逐个额外信号去全量中枢表里按 (zg, zd) 找它的中枢
找得到且边界一致 -> 窗口错位用户是对的我的归因错了
找不到或边界不同 -> 边界重画我的归因成立
"""
from __future__ import annotations
import argparse
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step69_mech.feather"
WIN, MAX_GROW, SCAN = 2001, 500, 200
TOL = 1e-6
def replay(sym: str, ltf: str, rows: int, steps: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
ensure_timestamp,
find_fast_bsp3,
zones_from_zs_list,
)
from lib.data import fetch_ohlcv
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
if df is None or len(df) < WIN + steps + 100:
return None
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
full = TF_DF(df, 1, ltf)
cdf = ensure_timestamp(full.dataframe)
zf = zones_from_zs_list(full.cal_bi_zs_list_pure(full.bi_list), cdf)
if zf is None or zf.empty:
return None
sig_full = find_fast_bsp3(cdf, zf)
full_keys = {(int(r.entry_idx), int(r.direction))
for r in sig_full.itertuples()} if not sig_full.empty \
else set()
fzg = zf["zg"].to_numpy(float)
fzd = zf["zd"].to_numpy(float)
fav = zf["available_ts"].to_numpy()
ts_all = cdf["timestamp"].to_numpy()
rec: dict[tuple, dict] = {}
chan, anchor = None, 0
for i in range(WIN, len(df)):
if chan is None or (i - anchor) >= MAX_GROW:
w = df.iloc[i - WIN + 1:i + 1].copy()
chan = TF_DF(w, 1, ltf)
chan.init_stream(w, 1, ltf)
anchor = i
else:
chan.append_bar(df.iloc[i])
try:
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
if not zl:
continue
sub = ensure_timestamp(chan.dataframe)
z = zones_from_zs_list(zl, sub)
if z is None or z.empty:
continue
s = find_fast_bsp3(sub, z)
if s is None or s.empty:
continue
last = len(sub) - 1
s = s[s["entry_idx"].astype(int) == last]
if s.empty or "zone_i" not in s.columns:
continue
# find_fast_bsp3 的输出自带 zg/zd,直接 merge 会加后缀,
# 所以中枢侧的列全部改名再接
zc = z[["zg", "zd", "available_ts"]].rename(columns={
"zg": "z_zg", "zd": "z_zd", "available_ts": "z_av"})
zc["zone_i"] = np.arange(len(z))
s = s.drop(columns=[c for c in ("z_zg", "z_zd", "z_av")
if c in s.columns])
s = s.merge(zc, on="zone_i", how="left")
except Exception: # noqa: BLE001
continue
for r in s.itertuples():
k = (i, int(r.direction))
if k in rec:
continue
# 该中枢在全量表里是否存在(按边界匹配,边界是不该漂的量)
zg_, zd_ = float(r.z_zg), float(r.z_zd)
m = (np.abs(fzg - zg_) <= TOL * max(1.0, abs(zg_))) \
& (np.abs(fzd - zd_) <= TOL * max(1.0, abs(zd_)))
j = int(np.argmax(m)) if m.any() else -1
rec[k] = {
"sym": sym, "entry_idx": i, "direction": int(r.direction),
"in_full": k in full_keys,
"zone_found": bool(m.any()),
"z_zg": zg_, "z_zd": zd_,
"avail_rt": int(r.z_av),
"avail_full": int(fav[j]) if j >= 0 else -1,
"ts_entry": int(ts_all[i]) if i < len(ts_all) else -1,
}
return pd.DataFrame(list(rec.values())) if rec else None
except Exception as e: # noqa: BLE001
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
return None
def report(d: pd.DataFrame, ltf: str) -> None:
ex = d[~d.in_full]
print("\n" + "=" * 92)
print("【一】判据:额外信号所在的中枢,在全量表里找得到吗")
print("=" * 92)
print(f"回放信号 {len(d)} · 额外 {len(ex)}")
print(f"**额外信号中,其中枢按 (zg,zd) 在全量表里找得到的:"
f"{ex.zone_found.mean()*100:.1f}%**")
print(f"(对照:非额外信号 {d[d.in_full].zone_found.mean()*100:.1f}%")
print("""
100% -> 中枢边界没变**扫描窗口错位**用户对我的重画归因错
明显偏低 -> 中枢确实消失或改边界重画成立""")
print("\n" + "=" * 92)
print("【一b】数量级对账:中枢层面只有 6.5% 被改,信号层面却 74% 是额外的")
print("=" * 92)
print("§5.41 的 A/B 实测:`bis[-1]` 口径下确认时刻被改 6.5%、中枢消失 9.0%")
print("若信号层面的 74% 成立,必然有放大机制。怀疑是 `max_per_zone=1`")
print(" 每个中枢只返回**第一个**入场点,而扫描起点随 available_ts 棘轮后移,")
print(" 越过旧入场点后,同一中枢会重新产出一个「第一个」——全量只用最终值,")
print(" 所以每中枢至多一个信号,实时却能反复触发。")
for nm, x in [("额外信号", ex), ("非额外信号", d[d.in_full])]:
if x.empty:
continue
nz = x.groupby(["sym", "z_zg", "z_zd"]).size() \
if "z_zg" in x.columns else None
if nz is None:
print(" (缺 z_zg/z_zd 列,跳过)")
break
print(f"\n{nm}{len(x)} 个信号,落在 {len(nz)} 个不同中枢上 "
f"-> 每中枢 {len(x)/len(nz):.2f}")
print(f" 同一中枢触发次数分布 中位 {nz.median():.0f} "
f"P90 {nz.quantile(.9):.0f} 最大 {nz.max()}")
print("""
额外信号的每中枢次数显著 > 1 非额外 1放大机制坐实
6.5% 的中枢改动通过棘轮重扫放大成信号层面的几百个""")
g = ex[ex.zone_found & (ex.avail_full > 0)].copy()
if g.empty:
return
bar_ms = {"1m": 60_000, "5m": 300_000, "15m": 900_000}.get(ltf, 300_000)
g["drift_bars"] = (g.avail_full - g.avail_rt) / bar_ms
g["from_rt"] = (g.ts_entry - g.avail_rt) / bar_ms
g["from_full"] = (g.ts_entry - g.avail_full) / bar_ms
print("\n" + "=" * 92)
print("【二】available_ts 漂了多少,以及入场落在谁的扫描窗口里")
print("=" * 92)
print(f"avail 漂移(全量 − 实时,根) 中位 {g.drift_bars.median():.0f} "
f"P25 {g.drift_bars.quantile(.25):.0f} "
f"P75 {g.drift_bars.quantile(.75):.0f}")
print(f" §5.41 记的是中位晚 62 分钟,本表 {ltf} 下即 "
f"{62*60_000/bar_ms:.0f} 根,可交叉验证")
print(f"\n入场距实时 avail(根) 中位 {g.from_rt.median():.0f} "
f"(应落在 0~{SCAN} 内,否则实时也扫不到)")
print(f"入场距全量 avail(根) 中位 {g.from_full.median():.0f}")
out = ((g.from_full < 0) | (g.from_full > SCAN)).mean()
print(f"\n**入场落在全量扫描窗口 [0,{SCAN}] 之外的比例:{out*100:.1f}%**")
print("""
这是机制的直接证据比例高 -> 全量根本没扫到那个时段所以没有这个信号
与中枢是否重画无关其中 from_full < 0 表示入场早于全量的可用时刻
**实时抢跑了**中枢还没吸收完就下单""")
early = (g.from_full < 0).mean()
print(f" 其中抢跑(早于全量 avail):{early*100:.1f}%")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--ltf", default="5m")
ap.add_argument("--rows", type=int, default=45_000)
ap.add_argument("--steps", type=int, default=20_000)
ap.add_argument("--workers", type=int, default=5)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
report(pd.read_feather(OUT), args.ltf)
return
syms = [s.strip() for s in args.symbols.split(",")]
print(f"[机制判定] {len(syms)}× {args.steps} 根 · {args.ltf}\n",
flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex_:
fut = {ex_.submit(replay, s, args.ltf, args.rows, args.steps): s
for s in syms}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
print(f" [{i}/{len(syms)}] {fut[f]} "
f"{0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d, args.ltf)
if __name__ == "__main__":
main()
+280
View File
@@ -0,0 +1,280 @@
"""把 B4 按「中枢末笔是否已确认」拆开——之前的统计把两种混在一起了。
用户指出B4 有两种**没确认的会出现然后消失确认的不会**而我在 step63/68
里统计的是 `find_fast_bsp3` 的全部输出它的返回列里根本没有确认标志
entry_idx/direction/bo_idx/pb_idx/lag/depth/zg/zd/width_pct/occ/zone_i
**两种被混在一起了**所以额外信号 74%这个数字不能直接拿来说实盘
确认状态在更上游`zones_from_zs_list`
sure_key = str(getattr(key_bi, "sure_time", "") or "")
end_key = str(getattr(key_bi, "end_time", "") or "")
avail = ts_of.get(sure_key) or ts_of.get(end_key) # ← 静默退回 end_time
`ChanBI` 初始 `is_sure=False / sure_time=None`确认时才 `set_is_sure(True, ...)`
所以**末笔未确认时 available_ts 退回 end_time end_time 随笔延伸而移动**
中枢的可用时刻跟着漂 这正是出现然后消失的那一种笔一旦确认
`sure_time` 固定中枢不再动
这也解释了 §5.41 的数量级中枢层面确认时刻只被改 6.5%而我在信号层面看到 74%
本脚本按 `zs.bi_list[-1].is_sure` 把信号拆成两组分别看
额外率 未确认组应显著高会出现然后消失已确认组应接近 0
收益 若亏损集中在未确认组那么修法就是**信号侧加一道 is_sure **
而不是动出场参数或放弃 B4
同时要查的第二件事**实盘路径到底交易哪一种** `shadow_signal.py` 的三道
滤网是同向 + 阶梯 + ATR 门控**没有 is_sure 这一道**若未确认组确实是亏损源
且实盘没有挡它那这道门就是要补的东西
"""
from __future__ import annotations
import argparse
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step70_sure.feather"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
WIN, MAX_GROW, GATE_BP = 2001, 500, 8.0
def _sure_map(zs_list) -> dict:
"""(zg, zd) -> 末笔是否已确认。zones_from_zs_list 会按 available_ts 重排,
索引对不上所以用中枢边界当键接回去"""
m = {}
for zs in zs_list:
bis = getattr(zs, "bi_list", []) or []
if not bis:
continue
m[(round(float(zs.zg), 10), round(float(zs.zd), 10))] = \
bool(getattr(bis[-1], "is_sure", False))
return m
def replay(sym: str, ltf: str, htf: str, rows: int,
steps: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
ensure_timestamp,
find_fast_bsp3,
zones_from_zs_list,
)
from lib.data import fetch_ohlcv
from lib.fx_signal import extract_fx_signals, signals_to_frame
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
if df is None or len(df) < WIN + steps + 100:
return None
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
full = TF_DF(df, 1, ltf)
cdf = ensure_timestamp(full.dataframe)
zsf = full.cal_bi_zs_list_pure(full.bi_list)
if not zsf:
return None
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zsf, cdf))
full_keys = {(int(r.entry_idx), int(r.direction))
for r in sig_full.itertuples()} if not sig_full.empty \
else set()
dh = fetch_ohlcv(f"{sym}/USDT:USDT", htf, rows)
tl = None
if dh is not None and len(dh) > 500:
ch = TF_DF(dh, 1, htf)
tl = htf_fx_timeline(
signals_to_frame(extract_fx_signals(ch, ch.dataframe)),
ch.dataframe)
rec: dict[tuple, dict] = {}
chan, anchor = None, 0
for i in range(WIN, len(df)):
if chan is None or (i - anchor) >= MAX_GROW:
w = df.iloc[i - WIN + 1:i + 1].copy()
chan = TF_DF(w, 1, ltf)
chan.init_stream(w, 1, ltf)
anchor = i
else:
chan.append_bar(df.iloc[i])
try:
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
if not zl:
continue
sub = ensure_timestamp(chan.dataframe)
z = zones_from_zs_list(zl, sub)
if z is None or z.empty:
continue
sm = _sure_map(zl)
pg, pdn = z["zg"].shift(), z["zd"].shift()
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
z["zone_i"] = np.arange(len(z))
z["z_sure"] = [
sm.get((round(float(a), 10), round(float(b), 10)), False)
for a, b in zip(z["zg"], z["zd"])]
s = find_fast_bsp3(sub, z)
if s is None or s.empty:
continue
last = len(sub) - 1
s = s[s["entry_idx"].astype(int) == last]
if s.empty or "zone_i" not in s.columns:
continue
s = s.merge(z[["zone_i", "z_above", "z_below", "z_sure"]],
on="zone_i", how="left")
if tl is not None:
s = attach_htf_context(s, sub, tl, "h1")
except Exception: # noqa: BLE001
continue
for r in s.itertuples():
k = (i, int(r.direction))
if k in rec:
continue
push = getattr(r, "z_above" if r.direction == 1
else "z_below", None)
ag = getattr(r, "h1_agree", 0)
rec[k] = {
"sym": sym, "entry_idx": i, "direction": int(r.direction),
"in_full": k in full_keys,
"z_sure": bool(getattr(r, "z_sure", False)),
"ladder_ok": int(bool(pd.notna(push) and bool(push))),
"h1_agree": int(ag) if pd.notna(ag) else 0,
}
if not rec:
return None
r = pd.DataFrame(list(rec.values()))
from lib.exit_model import cfg_name, walk_exits
atr = cdf["atr"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
r = r[(r.entry_idx < len(cdf) - 2)
& np.isfinite(atr[r.entry_idx.values])
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
if r.empty:
return None
res = walk_exits(cdf, pd.DataFrame({
"entry_idx": r.entry_idx.values,
"direction": r.direction.values}), [SL], [RUNNER], [MAXB],
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
if len(res) != len(r):
return None
for c in ("g", "r", "c"):
r[c] = res[f"{cfg}_{c}"].to_numpy()
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
r["gate_ok"] = (r.atr_pct * 1e4 >= GATE_BP).astype(int)
r["pass_all"] = ((r.h1_agree == 1) & (r.ladder_ok == 1)
& (r.gate_ok == 1)).astype(int)
return r
except Exception as e: # noqa: BLE001
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
return None
def perf(g: pd.DataFrame) -> dict | None:
from lib.exit_model import fee_of, taker_notional
if len(g) < 15:
return None
net = g.g.values - fee_of(g.r.values, g.c.values)
gR = g.g.values / (SL * g.atr_pct.values)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
return {
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean()
/ taker_notional(g.r.values, g.c.values).mean() * 1e4, 2),
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
}
def report(d: pd.DataFrame) -> None:
print("\n" + "=" * 92)
print("【一】判据:额外信号是不是集中在「末笔未确认」那一组")
print("=" * 92)
rows = []
for k, g in d.groupby(d.z_sure):
rows.append({"中枢末笔": "已确认" if k else "未确认", "信号数": len(g),
"额外(全量没有)": int((~g.in_full).sum()),
"额外率": f"{(~g.in_full).mean()*100:.1f}%"})
print(pd.DataFrame(rows).to_string(index=False))
print("""
用户的判断没确认的会出现然后消失确认的不会
若已确认组额外率接近 0 -> 判断成立之前 74% 是把两种混在一起统计的结果""")
print("\n" + "=" * 92)
print("【二】亏损是不是也集中在未确认组")
print("=" * 92)
rows = []
for k, g in d.groupby(d.z_sure):
nm = "已确认" if k else "未确认"
for lab, x in [("全部", g), ("三道滤网后", g[g.pass_all == 1])]:
s = perf(x)
if s:
rows.append({"中枢末笔": nm, "口径": lab, **s})
print(pd.DataFrame(rows).to_string(index=False))
print("\n" + "=" * 92)
print("【三】若补一道 is_sure 门,实盘口径会变成什么样")
print("=" * 92)
rows = []
for nm, x in [
("现状:三道滤网", d[d.pass_all == 1]),
("**加 is_sure 门**", d[(d.pass_all == 1) & d.z_sure]),
(" 对照:仅未确认", d[(d.pass_all == 1) & ~d.z_sure]),
]:
s = perf(x)
if s:
rows.append({"口径": nm, **s})
print(pd.DataFrame(rows).to_string(index=False))
print("""
`shadow_signal.py` 的三道滤网是同向 + 阶梯 + ATR 门控**没有 is_sure**
若加上这道门 PF 明显回升那它就是要补进信号路径的东西""")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--ltf", default="5m")
ap.add_argument("--htf", default="30m")
ap.add_argument("--rows", type=int, default=45_000)
ap.add_argument("--steps", type=int, default=20_000)
ap.add_argument("--workers", type=int, default=5)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
report(pd.read_feather(OUT))
return
syms = [s.strip() for s in args.symbols.split(",")]
print(f"[确认态拆分] {len(syms)}× {args.steps} 根 · {args.ltf}\n",
flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(replay, s, args.ltf, args.htf, args.rows,
args.steps): s for s in syms}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
print(f" [{i}/{len(syms)}] {fut[f]} "
f"{0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d)
if __name__ == "__main__":
main()
+211
View File
@@ -0,0 +1,211 @@
"""逐笔追「额外信号」:全量那一遍到底为什么没产出它。
用户说原来的计算是没有问题的是你算错了之前几轮都是统计口径
容易把自己的 bug 说成市场现象这次不做统计挑具体信号逐个对账
对每个额外信号把两边的中间量全摆出来
回放侧 中枢 (zg,zd)available_ts扫描起点入场根
全量侧 同一个中枢是否存在它的 available_ts扫描区间 [start, start+200]
入场根落不落在这个区间里`diag` 记的拒绝原因
四种可能的结论指向完全不同的处理
A 全量里那个中枢的扫描区间**不覆盖**入场根
-> available_ts 棘轮机制成立不是 bug
B 中枢在全量里**不存在**
-> 中枢集合本身有差异要查是不是我窗口用错了
C 区间覆盖了全量却仍没出信号
-> 两边输入不同我传错了 df/zones**是我的 bug**
D 入场根索引对不上差几根
-> 索引口径错`chan.dataframe` 与原始 df 不是 1:1**是我的 bug**
D 尤其要查回放里我用原始 df 的下标 i key全量用的是 `cdf` entry_idx
两者只有在 `TF_DF.dataframe` 与输入逐行对齐时才等价
"""
from __future__ import annotations
import argparse
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
WIN, MAX_GROW, SCAN = 2001, 500, 200
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--sym", default="BTC")
ap.add_argument("--tf", default="5m")
ap.add_argument("--rows", type=int, default=45_000)
ap.add_argument("--steps", type=int, default=3_000)
ap.add_argument("--show", type=int, default=8)
args = ap.parse_args()
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
ensure_timestamp,
find_fast_bsp3,
zones_from_zs_list,
)
from lib.data import fetch_ohlcv
df = fetch_ohlcv(f"{args.sym}/USDT:USDT", args.tf, args.rows)
df = df.iloc[-(WIN + args.steps):].reset_index(drop=True)
full = TF_DF(df, 1, args.tf)
cdf = ensure_timestamp(full.dataframe)
print("=" * 92)
print("【0】先查 D:索引口径是否 1:1")
print("=" * 92)
print(f"原始 df 行数 {len(df)} · TF_DF.dataframe 行数 {len(cdf)} "
f"-> {'一致' if len(df) == len(cdf) else '**不一致,索引口径有问题**'}")
if len(df) == len(cdf):
t_df = pd.to_datetime(df["date"])
t_cd = pd.to_datetime(cdf["date"])
if t_df.dt.tz is not None:
t_df = t_df.dt.tz_localize(None)
if t_cd.dt.tz is not None:
t_cd = t_cd.dt.tz_localize(None)
same = int((t_df.values == t_cd.values).sum())
print(f"逐行时间戳相同 {same}/{len(df)} "
f"-> {'逐行对齐' if same == len(df) else '**有错位**'}")
zsf = full.cal_bi_zs_list_pure(full.bi_list)
zf = zones_from_zs_list(zsf, cdf)
diag_full: dict = {}
sig_full = find_fast_bsp3(cdf, zf, diag=diag_full)
fkeys = {(int(r.entry_idx), int(r.direction))
for r in sig_full.itertuples()}
ts = cdf["timestamp"].to_numpy()
print(f"\n全量:中枢 {len(zf)} 个,信号 {len(sig_full)}")
print(f"全量 diag{diag_full}")
# ---- 回放 ----
rec = []
chan, anchor = None, 0
for i in range(WIN, len(df)):
if chan is None or (i - anchor) >= MAX_GROW:
w = df.iloc[i - WIN + 1:i + 1].copy()
chan = TF_DF(w, 1, args.tf)
chan.init_stream(w, 1, args.tf)
anchor = i
else:
chan.append_bar(df.iloc[i])
try:
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
if not zl:
continue
sub = ensure_timestamp(chan.dataframe)
z = zones_from_zs_list(zl, sub)
if z is None or z.empty:
continue
s = find_fast_bsp3(sub, z)
if s is None or s.empty:
continue
last = len(sub) - 1
s = s[s["entry_idx"].astype(int) == last]
if s.empty:
continue
except Exception: # noqa: BLE001
continue
for r in s.itertuples():
zi = int(r.zone_i)
rec.append({
"i": i, "d": int(r.direction),
"in_full": (i, int(r.direction)) in fkeys,
"zg": float(z.zg.iloc[zi]), "zd": float(z.zd.iloc[zi]),
"avail_rt": int(z.available_ts.iloc[zi]),
"win_len": len(sub),
"bo": int(r.bo_idx), "last": last,
})
rp = pd.DataFrame(rec)
if rp.empty:
print("回放无信号")
return
print(f"\n回放:信号 {len(rp)} 个 · 其中全量也有 "
f"{int(rp.in_full.sum())} · 额外 {int((~rp.in_full).sum())}")
# ---- 逐笔对账 ----
print("\n" + "=" * 92)
print(f"【1】抽 {args.show} 个额外信号逐笔对账")
print("=" * 92)
fzg, fzd = zf.zg.to_numpy(float), zf.zd.to_numpy(float)
fav = zf.available_ts.to_numpy()
n = len(cdf)
rows = []
for r in rp[~rp.in_full].head(args.show).itertuples():
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
if not m.any():
rows.append({"入场根": r.i, "中枢在全量": "**不存在**",
"结论": "B 中枢集合有差异"})
continue
j = int(np.argmax(m))
start = int(np.searchsorted(ts, fav[j], side="left"))
end = min(start + SCAN, n)
cover = start <= r.i < end
rows.append({
"入场根": r.i, "中枢在全量": "存在",
"回放avail根": int(np.searchsorted(ts, r.avail_rt, side="left")),
"全量avail根": start,
"棘轮(根)": start - int(np.searchsorted(ts, r.avail_rt,
side="left")),
"全量扫描区间": f"[{start},{end})",
"覆盖入场根": "" if cover else "",
"结论": "C **是我的bug**" if cover else "A 棘轮,机制成立",
})
print(pd.DataFrame(rows).to_string(index=False))
print("""
A = 全量扫描区间不覆盖入场根available_ts 棘轮后移机制成立
B = 中枢在全量里不存在 -> 中枢集合有差异要查窗口
C = 区间覆盖了全量却没出信号 -> 两边输入不同是我的 bug""")
print("\n" + "=" * 92)
print("【2】全体额外信号按结论归类")
print("=" * 92)
cnt = {"A 棘轮": 0, "B 中枢不存在": 0, "C 我的bug": 0}
for r in rp[~rp.in_full].itertuples():
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
if not m.any():
cnt["B 中枢不存在"] += 1
continue
j = int(np.argmax(m))
start = int(np.searchsorted(ts, fav[j], side="left"))
cnt["C 我的bug" if start <= r.i < min(start + SCAN, n)
else "A 棘轮"] += 1
tot = max(sum(cnt.values()), 1)
print(pd.DataFrame([{"结论": k, "个数": v, "占比": f"{v/tot*100:.1f}%"}
for k, v in cnt.items()]).to_string(index=False))
print("\n" + "=" * 92)
print("【3】按信号条数统计是否被同一中枢的重复触发放大了")
print("=" * 92)
print("上表可见 2691/2707/2750 的全量 avail 同为 2767,是**同一个中枢**触发三次。")
print("`max_per_zone=1` 只保证「每次扫描返回一个」,但扫描起点随棘轮后移,")
print("越过旧入场点后同一中枢会重新产出「第一个」——于是按条数统计被放大。\n")
rows = []
for nm, x in [("额外", rp[~rp.in_full]), ("全量也有", rp[rp.in_full])]:
if x.empty:
continue
nz = x.groupby(["zg", "zd"]).size()
rows.append({"分组": nm, "信号条数": len(x), "不同中枢数": len(nz),
"每中枢触发": round(len(x) / len(nz), 2),
"最多触发": int(nz.max())})
print(pd.DataFrame(rows).to_string(index=False))
print("""
额外组每中枢触发 >> 1 全量也有 1则先前那个额外信号占 74%
**按条数**统计的放大结果不等于实盘会多开 74% 的仓
真实多开多少取决于执行层对同一中枢是否去重/冷却 那一层仍未审计""")
if __name__ == "__main__":
main()
+21 -9
View File
@@ -108,7 +108,8 @@ def analyze():
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
} for bi in analysis_result['bi_list'] if bi.is_sure],
# 添加未完成笔列表
'uncompleted_bi_list': [{
@@ -118,7 +119,8 @@ def analyze():
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
} for bi in analysis_result['bi_list'] if not bi.is_sure],
'seg_list': [{
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
@@ -126,7 +128,9 @@ def analyze():
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
'direction': convert_direction(seg.dir)
'direction': convert_direction(seg.dir),
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
} for seg in analysis_result['seg_list'] if seg.is_sure],
# 添加未完成线段列表
'uncompleted_seg_list': get_uncompleted_seg_list(analysis_result['seg_list'], client_tz),
@@ -305,7 +309,8 @@ def analyze():
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
} for bi in element_analysis['bi_list'] if bi.is_sure]
# 添加次周期未完成笔列表
result['element_uncompleted_bi_list'] = [{
@@ -315,7 +320,8 @@ def analyze():
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
} for bi in element_analysis['bi_list'] if not bi.is_sure]
# 添加小周期K线数据
@@ -341,7 +347,9 @@ def analyze():
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
'direction': convert_direction(seg.dir)
'direction': convert_direction(seg.dir),
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
} for seg in element_analysis['seg_list'] if seg.is_sure]
# 添加次周期未完成线段列表
result['element_uncompleted_seg_list'] = get_uncompleted_seg_list(element_analysis['seg_list'], client_tz)
@@ -446,7 +454,8 @@ def analyze():
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
} for bi in sub_sub_analysis['bi_list'] if bi.is_sure]
result['sub_sub_uncompleted_bi_list'] = [{
'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(),
@@ -455,7 +464,8 @@ def analyze():
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high,
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
} for bi in sub_sub_analysis['bi_list'] if not bi.is_sure]
# 次次周期 KLC 列表
result['sub_sub_klc_list'] = [{
@@ -477,7 +487,9 @@ def analyze():
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
'direction': convert_direction(seg.dir)
'direction': convert_direction(seg.dir),
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
} for seg in sub_sub_analysis['seg_list'] if seg.is_sure]
result['sub_sub_uncompleted_seg_list'] = get_uncompleted_seg_list(sub_sub_analysis['seg_list'], client_tz)
result['sub_sub_zs_list'] = [{
+73
View File
@@ -0,0 +1,73 @@
"""把 data_provider 的资金面转给 Web,浏览器不直连交易所。"""
from __future__ import annotations
import logging
import requests
from flask import Blueprint, jsonify, request
from services.runtime.market_data import (
fetch_derivatives,
fetch_sentiment_latest,
fetch_sentiment_metrics,
)
logger = logging.getLogger(__name__)
bp = Blueprint("provider", __name__)
def _http_error(exc: requests.HTTPError):
status = 502
detail = str(exc)
if exc.response is not None:
status = exc.response.status_code or 502
try:
body = exc.response.json()
detail = body.get("detail") or body.get("error") or detail
except Exception:
detail = exc.response.text or detail
return jsonify({"error": detail}), status
@bp.route("/api/derivatives")
def api_derivatives():
symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip()
exchange = (request.args.get("exchange") or "").strip() or None
try:
return jsonify(fetch_derivatives(symbol, exchange))
except requests.HTTPError as exc:
return _http_error(exc)
except Exception as exc:
logger.warning("derivatives 中转失败: %s", exc)
return jsonify({"error": str(exc)}), 503
@bp.route("/api/sentiment/latest")
def api_sentiment_latest():
symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip()
try:
return jsonify(fetch_sentiment_latest(symbol))
except requests.HTTPError as exc:
return _http_error(exc)
except Exception as exc:
logger.warning("sentiment latest 中转失败: %s", exc)
return jsonify({"error": str(exc)}), 503
@bp.route("/api/sentiment/metrics")
def api_sentiment_metrics():
metric = (request.args.get("metric") or "").strip()
if not metric:
return jsonify({"error": "metric required"}), 400
symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip()
start = request.args.get("start", type=int)
end = request.args.get("end", type=int)
limit = request.args.get("limit", type=int)
try:
return jsonify(fetch_sentiment_metrics(metric, symbol, start=start, end=end, limit=limit))
except requests.HTTPError as exc:
return _http_error(exc)
except Exception as exc:
logger.warning("sentiment 中转失败: %s", exc)
return jsonify({"error": str(exc)}), 503
+2
View File
@@ -13,6 +13,7 @@ from flask import Flask
from config import FLASK_HOST, FLASK_PORT
from api.analyze import bp as analyze_bp
from api.pages import bp as pages_bp
from api.provider import bp as provider_bp
from api.symbols import bp as symbols_bp
from api.trend import bp as trend_bp
@@ -23,6 +24,7 @@ def create_app() -> Flask:
app.register_blueprint(analyze_bp)
app.register_blueprint(symbols_bp)
app.register_blueprint(trend_bp)
app.register_blueprint(provider_bp)
return app
+3
View File
@@ -54,6 +54,9 @@ from .market_data import ( # noqa: F401
get_crypto_kl_data,
get_a_stock_kl_data,
load_crypto_symbols,
fetch_derivatives,
fetch_sentiment_metrics,
fetch_sentiment_latest,
)
from .indicators import ( # noqa: F401
add_indicators,
+4
View File
@@ -48,6 +48,10 @@ def analyze_chan(df, symbol=None, timeframe=None):
for bi in bi_list:
bi.cal_macd_div()
#print(bi.start_time, bi.macd_hist, bi.macd_div)
for seg in seg_list:
seg.cal_macdhist()
for seg in seg_list:
seg.cal_macd_div()
# 添加ChanMACD分析(复用 get_klc_list 内已算好的结果,避免同周期二次全量分析)
chan_macd = None
+31
View File
@@ -319,3 +319,34 @@ def load_crypto_symbols(limit=200):
except Exception:
return DEFAULT_SYMBOLS[:limit]
def _provider_get(path, params, timeout=8):
resp = requests.get(f"{DATA_SERVICE_URL}{path}", params=params, timeout=timeout)
resp.raise_for_status()
return resp.json()
def fetch_derivatives(symbol, exchange=None):
"""当前资金面快照。只打 data_provider,不打交易所。"""
params = {"symbol": symbol}
if exchange:
params["exchange"] = exchange
return _provider_get("/api/derivatives", params)
def fetch_sentiment_metrics(metric, symbol, start=None, end=None, limit=None):
"""情绪/资金面序列。只打 data_provider。"""
params = {"metric": metric, "symbol": symbol}
if start is not None:
params["start"] = int(start)
if end is not None:
params["end"] = int(end)
if limit is not None:
params["limit"] = int(limit)
return _provider_get("/api/sentiment/metrics", params)
def fetch_sentiment_latest(symbol):
"""情绪面最新快照。只打 data_provider。"""
return _provider_get("/api/sentiment/latest", {"symbol": symbol})
+11 -3
View File
@@ -98,8 +98,14 @@ def serialize_chan_macd_data(chan_macd_data, client_tz):
# 序列化unittf_list(兼容新结构与枚举类型)
for unittf in chan_macd_data.get('unittf_list', []):
try:
dir_value = getattr(unittf, 'uinttf_dir', None)
dir_value = getattr(unittf, 'unittf_dir', None)
dir_name = getattr(dir_value, 'name', dir_value if isinstance(dir_value, str) else None)
if dir_name == 'ABOVE':
dir_num = 1
elif dir_name == 'UNDER':
dir_num = -1
else:
dir_num = 0
start_t = getattr(unittf, 'start_type', None)
start_type = getattr(start_t, 'name', start_t)
end_t = getattr(unittf, 'end_type', None)
@@ -114,7 +120,7 @@ def serialize_chan_macd_data(chan_macd_data, client_tz):
unittf_data = {
'start_time': format_time_safely(getattr(unittf, 'start_time', None), client_tz),
'end_time': format_time_safely(getattr(unittf, 'end_time', None), client_tz) if getattr(unittf, 'end_time', None) else None,
'dir': dir_name, # 'ABOVE' | 'UNDER' | None
'dir': dir_num,
'start_type': start_type, # e.g. 'START' | 'CROSS0' | 'NEAR0_UP' | 'NEAR0_DOWN'
'end_type': end_type,
'invalid': getattr(unittf, 'invalid', False),
@@ -307,7 +313,9 @@ def get_uncompleted_seg_list(seg_list, client_tz):
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
'direction': convert_direction(seg.dir)
'direction': convert_direction(seg.dir),
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
}
if is_last:
+1
View File
@@ -46,6 +46,7 @@ DEFAULT_TIMEFRAME_LABELS = OrderedDict([
("5m", "5分钟"),
("15m", "15分钟"),
("30m", "30分钟"),
("45m", "45分钟"),
("1h", "1小时"),
("2h", "2小时"),
("4h", "4小时"),
+4 -4
View File
@@ -94,19 +94,19 @@ def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys):
def compute_timeframe_defaults(labels_ordered):
"""
根据已排序的周期 中文标签映射计算主 / / 次次周期默认值
默认偏好 4h 1h次次 15m
默认偏好 45m 15m次次 5m
labels_ordered: OrderedDict 或按插入顺序排列的 dict
"""
if not labels_ordered:
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
timeframe_keys = list(labels_ordered.keys())
preferred_main = next((tf for tf in ['4h', '1h', '15m'] if tf in labels_ordered), None)
preferred_main = next((tf for tf in ['45m', '30m', '1h'] if tf in labels_ordered), None)
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
if default_main not in labels_ordered and timeframe_keys:
default_main = timeframe_keys[0]
default_element = _prefer_smaller(['1h', '15m'], labels_ordered, default_main, timeframe_keys)
default_sub_sub = _prefer_smaller(['15m', '5m'], labels_ordered, default_element, timeframe_keys)
default_element = _prefer_smaller(['15m', '5m', '30m'], labels_ordered, default_main, timeframe_keys)
default_sub_sub = _prefer_smaller(['5m', '1m', '15m'], labels_ordered, default_element, timeframe_keys)
return default_main, default_element, default_sub_sub, timeframe_keys
+27
View File
@@ -10,6 +10,33 @@ window.ChanApi = {
symbols: function() {
return fetch('/api/symbols').then(r => r.json());
},
derivatives: function(params) {
const q = new URLSearchParams(params || {});
return fetch('/api/derivatives?' + q.toString()).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) throw new Error((body && body.error) || r.statusText);
return body;
});
});
},
sentimentLatest: function(params) {
const q = new URLSearchParams(params || {});
return fetch('/api/sentiment/latest?' + q.toString()).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) throw new Error((body && body.error) || r.statusText);
return body;
});
});
},
sentimentMetrics: function(params) {
const q = new URLSearchParams(params || {});
return fetch('/api/sentiment/metrics?' + q.toString()).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) throw new Error((body && body.error) || r.statusText);
return body;
});
});
},
macdConfig: function(body) {
if (body === undefined) return fetch('/api/macd_config').then(r => r.json());
return fetch('/api/macd_config', {
+32 -13
View File
@@ -229,29 +229,29 @@ function updateTradingViewData(options) {
}
// 更新MACD数据
if (tvWidget.series.macdLineSeries && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) {
// 提取MACD数据
const macdKlineSrc = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? (currentData.element_kline_data || []) : (currentData.kline_data || []));
const macdSrc = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
if (tvWidget.series.macdLineSeries && macdSrc && macdKlineSrc && Array.isArray(macdKlineSrc)) {
const macdData = [];
const signalData = [];
const histogramData = [];
for (let i = 0; i < currentData.kline_data.length; i++) {
const kline = currentData.kline_data[i];
for (let i = 0; i < macdKlineSrc.length; i++) {
const kline = macdKlineSrc[i];
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
if (currentData.macd && currentData.macd.macd && currentData.macd.macd[i] !== undefined) {
if (macdSrc && macdSrc.macd && macdSrc.macd[i] !== undefined) {
macdData.push({
time: timestamp,
value: currentData.macd.macd[i]
value: macdSrc.macd[i]
});
signalData.push({
time: timestamp,
value: currentData.macd.signal[i]
value: macdSrc.signal[i]
});
// 设置直方图颜色
const histValue = currentData.macd.histogram[i];
const histValue = macdSrc.histogram[i];
histogramData.push({
time: timestamp,
value: histValue,
@@ -309,6 +309,9 @@ function updateTradingViewData(options) {
}
);
}
if (typeof refreshUnittfOverlayFromData === 'function') {
refreshUnittfOverlayFromData(currentData);
}
} catch (e) {
console.warn('更新ChanMACD标注失败:', e);
}
@@ -331,7 +334,8 @@ function updateTradingViewData(options) {
tvWidget.volumeChart,
tvWidget.atrChart,
tvWidget.macdChart,
tvWidget.chanMacdChart
tvWidget.chanMacdChart,
tvWidget.sentimentChart
].filter(Boolean);
const vr = clampedVisibleRange || savedVisibleRange;
@@ -409,8 +413,11 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
volume: false,
atr: false,
macd: false,
chanmacd: false
chanmacd: false,
sentiment: false
};
const sentimentChart = tvWidget && tvWidget.sentimentChart;
const sentimentChartContainer = tvWidget && tvWidget.sentimentChartContainer;
// 同步图表的时间范围
function syncCharts(sourceChart, sourceContainer) {
@@ -438,6 +445,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) {
try { chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
}
if (sentimentChart && sourceChart !== sentimentChart && sentimentChart.timeScale) {
try { sentimentChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
}
if (tvWidget && tvWidget.state) {
tvWidget.state.logicalRange = logicalRange;
@@ -458,7 +468,8 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
chart === volumeChart ? 'volume' :
chart === atrChart ? 'atr' :
chart === macdChart ? 'macd' :
chart === chanMacdChart ? 'chanmacd' : 'unknown';
chart === chanMacdChart ? 'chanmacd' :
chart === sentimentChart ? 'sentiment' : 'unknown';
const timeRangeHandler = () => {
if (!syncInProgress) {
@@ -515,6 +526,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
if (showMacd && chanMacdChartContainer && chanMacdChart) {
addChartSyncEvents(chanMacdChartContainer, chanMacdChart);
}
if (sentimentChartContainer && sentimentChart) {
addChartSyncEvents(sentimentChartContainer, sentimentChart);
}
// 窗口大小变化时重绘图表 — 使用可清理的方式注册
const resizeHandler = () => {
@@ -533,6 +547,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
if (showMacd && chanMacdChart && chanMacdChartContainer) {
chanMacdChart.applyOptions({ width: chanMacdChartContainer.clientWidth, height: chanMacdChartContainer.clientHeight });
}
if (sentimentChart && sentimentChartContainer) {
sentimentChart.applyOptions({ width: sentimentChartContainer.clientWidth, height: sentimentChartContainer.clientHeight });
}
setTimeout(() => { if (mainChart) syncCharts(mainChart, mainChartContainer); }, 200);
};
window.addEventListener('resize', resizeHandler);
@@ -549,9 +566,11 @@ function setupTooltip(mainChart, buyMarkers = [], sellMarkers = [], mainChartCon
// 初始化 U 显示状态(主/次周期分开控制)
const isShowUMain = $('#toggleUOnMain').is(':checked');
const isShowUElement = $('#toggleUOnElement').is(':checked');
const isShowUSubSub = $('#toggleUOnSubSub').is(':checked');
window.showUOnMain = isShowUMain;
window.showUOnElement = isShowUElement;
if (!isShowUMain && !isShowUElement) {
window.showUOnSubSub = isShowUSubSub;
if (!isShowUMain && !isShowUElement && !isShowUSubSub) {
// 隐藏时清空子图上的 U 标记
if (tvWidget.series && tvWidget.series.chanMacdLineSeries) {
try { tvWidget.series.chanMacdLineSeries.setMarkers([]); } catch (e) {}
+16 -16
View File
@@ -169,7 +169,7 @@ function updateTables(currentData) {
});
// 更新数据源信息显示
const selectedPeriod = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期');
const selectedPeriod = useSubSubPeriod ? '次次' : (useElementPeriod ? '' : '主');
const timeframe = useSubSubPeriod && data.sub_sub_timeframe ? data.sub_sub_timeframe : (useElementPeriod && data.element_timeframe ? data.element_timeframe : $('#timeframe').val());
$('#dataSourceText').html(`当前显示的是<strong>${selectedPeriod} (${timeframe})</strong> 数据`);
@@ -297,55 +297,55 @@ function setupDataSourceInfo(data) {
$('#kline-tab, #macd-tab').off('click').on('click', function() {
$('.data-source-info').show();
if (useSubSubPeriod && data.sub_sub_kline_data && data.sub_sub_kline_data.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 数据`);
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 数据`);
} else if (useElementPeriod && data.element_kline_data && data.element_kline_data.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 数据`);
$('#dataSourceText').html(`当前显示的是<strong> (${elementTimeframe})</strong> 数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 数据`);
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 数据`);
}
});
$('#bi-tab').off('click').on('click', function() {
$('.data-source-info').show();
if (useSubSubPeriod && data.sub_sub_bi_list && data.sub_sub_bi_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 笔数据`);
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 笔数据`);
} else if (useElementPeriod && data.element_bi_list && data.element_bi_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 笔数据`);
$('#dataSourceText').html(`当前显示的是<strong> (${elementTimeframe})</strong> 笔数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 笔数据`);
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 笔数据`);
}
});
$('#seg-tab').off('click').on('click', function() {
$('.data-source-info').show();
if (useSubSubPeriod && data.sub_sub_seg_list && data.sub_sub_seg_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 线段数据`);
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 线段数据`);
} else if (useElementPeriod && data.element_seg_list && data.element_seg_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 线段数据`);
$('#dataSourceText').html(`当前显示的是<strong> (${elementTimeframe})</strong> 线段数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 线段数据`);
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 线段数据`);
}
});
$('#zs-tab').off('click').on('click', function() {
$('.data-source-info').show();
if (useSubSubPeriod && data.sub_sub_zs_list && data.sub_sub_zs_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 中枢数据`);
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 中枢数据`);
} else if (useElementPeriod && data.element_zs_list && data.element_zs_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 中枢数据`);
$('#dataSourceText').html(`当前显示的是<strong> (${elementTimeframe})</strong> 中枢数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 中枢数据`);
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 中枢数据`);
}
});
$('#trade-points-tab').off('click').on('click', function() {
$('.data-source-info').show();
if (useSubSubPeriod && data.sub_sub_bsp_list && data.sub_sub_bsp_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 买卖点数据`);
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 买卖点数据`);
} else if (useElementPeriod && data.element_trade_points && data.element_trade_points.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 买卖点数据`);
$('#dataSourceText').html(`当前显示的是<strong> (${elementTimeframe})</strong> 买卖点数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 买卖点数据`);
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 买卖点数据`);
}
});
+1
View File
@@ -18,6 +18,7 @@ function initTradingView(symbol, timeframe) {
chartTvRenderChan(ctx);
chartTvRenderOverlays(ctx);
chartTvFinalize(ctx);
if (window.ChanDeriv) ChanDeriv.loadOverlays();
console.log('图表初始化完成');
} catch (e) {
-64
View File
@@ -178,38 +178,6 @@ function chartTvRenderChan(ctx) {
color: bi.direction === 1 ? '#dc3545' : '#28a745',
lineWidth: 1
});
// 在笔的末端添加macd_div值标记
if (bi.macd_div && bi.macd_div !== 0 && $('#showMainMacdDiv').is(':checked')) {
console.log(`添加主周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`);
const macdDivLabel = mainChart.addLineSeries({
lastValueVisible: false,
priceLineVisible: false,
color: 'transparent', // 设置为透明色
lineWidth: 0, // 线宽为0
});
// 添加一个透明的数据点用于承载标记
macdDivLabel.setData([
{ time: endTime, value: endPrice }
]);
// 主周期MACD背离标记根据笔方向显示,远离K线避免与分型重叠
const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar';
const textColor = bi.macd_div > 0 ? '#dc3545' : '#28a745';
// 只使用标记,不添加数据点
macdDivLabel.setMarkers([
{
time: endTime,
position: markerPosition,
color: textColor,
text: `${bi.macd_div.toFixed(2)}`, // 添加M前缀区分
size: 0.6, // 更小的尺寸,远离分型标记
}
]);
}
} catch (e) {
console.error('主周期笔处理出错:', e);
}
@@ -260,38 +228,6 @@ function chartTvRenderChan(ctx) {
color: bi.direction === 1 ? '#9c27b0' : '#673ab7',
lineWidth: 1
});
// 在笔的末端添加macd_div值标记
if (bi.macd_div && bi.macd_div !== 0 && $('#showElementMacdDiv').is(':checked')) {
console.log(`添加元素周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`);
const macdDivLabel = mainChart.addLineSeries({
lastValueVisible: false,
priceLineVisible: false,
color: 'transparent', // 设置为透明色
lineWidth: 0, // 线宽为0
});
// 添加一个透明的数据点用于承载标记
macdDivLabel.setData([
{ time: endTime, value: endPrice }
]);
// 次周期MACD背离标记使用不同位置,进一步避免重叠
const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar';
const textColor = bi.macd_div > 0 ? '#9c27b0' : '#673ab7';
// 只使用标记,不添加数据点
macdDivLabel.setMarkers([
{
time: endTime,
position: markerPosition,
color: textColor,
text: `${bi.macd_div.toFixed(2)}`, // 添加E前缀区分次周期
size: 0.4, // 更小的尺寸,让分型标记有更多空间
}
]);
}
} catch (e) {
console.error('次周期笔处理出错:', e);
}
+19 -2
View File
@@ -22,6 +22,8 @@ function chartTvFinalize(ctx) {
var atrChart = ctx.atrChart;
var macdChart = ctx.macdChart;
var chanMacdChart = ctx.chanMacdChart;
var sentimentChart = ctx.sentimentChart;
var sentimentChartContainer = ctx.sentimentChartContainer;
var createChartOptions = ctx.createChartOptions;
// 同步所有图表的时间轴配置
const hasPendingRestoreView = !!window._pendingRestoreView;
@@ -55,6 +57,9 @@ function chartTvFinalize(ctx) {
if (showMacd && macdChart) {
macdChart.timeScale().applyOptions(baseOptions);
}
if (sentimentChart) {
sentimentChart.timeScale().applyOptions(baseOptions);
}
};
// 首先同步时间轴设置
@@ -65,7 +70,8 @@ function chartTvFinalize(ctx) {
const visibleBarsCount = 200;
const allChartsNow = [mainChart, volumeChart, atrChart]
.concat(showMacd && macdChart ? [macdChart] : [])
.concat(showMacd && chanMacdChart ? [chanMacdChart] : []);
.concat(showMacd && chanMacdChart ? [chanMacdChart] : [])
.concat(sentimentChart ? [sentimentChart] : []);
const restoreOpts = function () {
const firstT = candles && candles.length ? candles[0].time : null;
const lastT = candles && candles.length ? candles[candles.length - 1].time : null;
@@ -103,6 +109,9 @@ function chartTvFinalize(ctx) {
if (showMacd && chanMacdChart) {
chanMacdChart.timeScale().setVisibleLogicalRange(logRange);
}
if (sentimentChart) {
sentimentChart.timeScale().setVisibleLogicalRange(logRange);
}
}
return;
}
@@ -117,6 +126,9 @@ function chartTvFinalize(ctx) {
if (showMacd && chanMacdChart) {
chanMacdChart.timeScale().setVisibleLogicalRange(logRange);
}
if (sentimentChart) {
sentimentChart.timeScale().setVisibleLogicalRange(logRange);
}
console.log('🔧 时间轴同步完成');
}
}, 50);
@@ -127,6 +139,8 @@ function chartTvFinalize(ctx) {
tvWidget.atrChart = atrChart;
tvWidget.macdChart = macdChart;
tvWidget.chanMacdChart = chanMacdChart;
tvWidget.sentimentChart = sentimentChart;
tvWidget.sentimentChartContainer = sentimentChartContainer;
tvWidget.state.isInitialized = true;
// 注册窗口卸载时释放资源,避免GPU内存泄漏
window.onbeforeunload = function() {
@@ -137,6 +151,7 @@ function chartTvFinalize(ctx) {
if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') tvWidget.macdChart.remove();
if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') tvWidget.chanMacdChart.remove();
if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') tvWidget.atrChart.remove();
if (tvWidget.sentimentChart && typeof tvWidget.sentimentChart.remove === 'function') tvWidget.sentimentChart.remove();
}
} catch (e) {}
};
@@ -220,6 +235,7 @@ function chartTvFinalize(ctx) {
const allCharts = [mainChart, volumeChart, atrChart];
if (showMacd && macdChart) allCharts.push(macdChart);
if (showMacd && chanMacdChart) allCharts.push(chanMacdChart);
if (sentimentChart) allCharts.push(sentimentChart);
// 检查是否有待恢复的视图(缩放 + 位置)
const pending = window._pendingRestoreView || pendingView;
@@ -243,7 +259,8 @@ function chartTvFinalize(ctx) {
console.log('🔧 最终同步可见范围:', visibleRange);
[volumeChart, atrChart].concat(
showMacd && macdChart ? [macdChart] : [],
showMacd && chanMacdChart ? [chanMacdChart] : []
showMacd && chanMacdChart ? [chanMacdChart] : [],
sentimentChart ? [sentimentChart] : []
).forEach(c => {
try { c.timeScale().setVisibleRange(visibleRange); } catch(e) {}
});
+81 -26
View File
@@ -1,5 +1,44 @@
/* chart_tv_indicators.js — volume / ATR / ChanMACD */
function formatSdMarkerText(separateDiv) {
const n = Number(separateDiv);
return `SD${n === 99999 ? 0 : n}`;
}
function shouldShowSdMarker(separateDiv) {
const n = Number(separateDiv);
return isFinite(n) && n > 0 && n !== 99999;
}
var SD_CD_MARKER_COLOR = '#dc3545';
var SD_CD_MARKER_SIZE = 1.2;
function histArrowShape(pos) {
return pos === 'belowBar' ? 'arrowDown' : 'arrowUp';
}
function makeSdMarker(ts, pos, separateDiv) {
return {
time: ts,
position: pos,
color: SD_CD_MARKER_COLOR,
shape: histArrowShape(pos),
text: formatSdMarkerText(separateDiv),
size: SD_CD_MARKER_SIZE
};
}
function makeCdMarker(ts, pos) {
return {
time: ts,
position: pos,
color: SD_CD_MARKER_COLOR,
shape: histArrowShape(pos),
text: 'CD',
size: SD_CD_MARKER_SIZE
};
}
function chartTvRenderIndicators(ctx) {
var symbol = ctx.symbol;
var timeframe = ctx.timeframe;
@@ -141,11 +180,11 @@ function chartTvRenderIndicators(ctx) {
// 使用与K线数据相同的数据源来确保时间对齐
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
const macdDataSource = useElementPeriod ?
(currentData.element_macd || currentData.macd) : // 如果有次周期MACD数据则使用,否则使用主周期
currentData.macd; // 主周期使用主周期MACD数据
const macdDataSource = useSubSubPeriod
? (currentData.sub_sub_macd || currentData.macd)
: (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
console.log('MACD数据源选择:', useElementPeriod ? '次周期' : '主周期');
console.log('MACD数据源选择:', useSubSubPeriod ? '次次周期' : (useElementPeriod ? '次周期' : '主周期'));
console.log('K线数据长度:', klineDataSource.length);
console.log('MACD数据:', macdDataSource);
@@ -209,7 +248,7 @@ function chartTvRenderIndicators(ctx) {
const chanMacdLineSeries = chanMacdChart.addLineSeries({
color: '#2962FF',
lineWidth: 1,
title: 'ChanMACD',
title: 'MACD',
lastValueVisible: false,
priceLineVisible: false,
});
@@ -370,7 +409,8 @@ function chartTvRenderIndicators(ctx) {
const k = klineArr[i];
if (!k || !k.date) continue;
const t = Math.floor(new Date(k.date).getTime() / 1000);
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
const hist = (macdObj.histogram && macdObj.histogram[i] !== undefined && macdObj.histogram[i] !== null) ? macdObj.histogram[i] : null;
const val = (hist !== null) ? hist : ((macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null);
map.set(t, val);
}
return map;
@@ -387,15 +427,15 @@ function chartTvRenderIndicators(ctx) {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
if (shouldShowSdMarker(item.separate_div)) {
const macdVal = mainMacdMap.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
mainMarkers.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
mainMarkers.push(makeSdMarker(ts, posSd, item.separate_div));
}
if (item.continue_div === true) {
const macdVal = mainMacdMap.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
mainMarkers.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
mainMarkers.push(makeCdMarker(ts, posCd));
}
if (item.near0_return && Number(item.near0_return) > 0) {
mainMarkers.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
@@ -409,15 +449,15 @@ function chartTvRenderIndicators(ctx) {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
if (shouldShowSdMarker(item.separate_div)) {
const macdVal = elementMacdMap.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
elementMarkers.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
elementMarkers.push(makeSdMarker(ts, posSd, item.separate_div));
}
if (item.continue_div === true) {
const macdVal = elementMacdMap.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
elementMarkers.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
elementMarkers.push(makeCdMarker(ts, posCd));
}
if (item.near0_return && Number(item.near0_return) > 0) {
elementMarkers.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
@@ -427,21 +467,24 @@ function chartTvRenderIndicators(ctx) {
const subSubCm = currentData.sub_sub_chan_macd || {};
const subSubMarkers = [];
const subSubMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data);
const subSubMacdMap = buildMacdTimeMap(
(currentData.sub_sub_macd || currentData.macd),
(currentData.sub_sub_kline_data || currentData.kline_data)
);
if (window.showUOnSubSub && Array.isArray(subSubCm.klu_list)) {
subSubCm.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
if (shouldShowSdMarker(item.separate_div)) {
const macdVal = subSubMacdMap.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
subSubMarkers.push({ time: ts, position: posSd, color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
subSubMarkers.push(makeSdMarker(ts, posSd, item.separate_div));
}
if (item.continue_div === true) {
const macdVal = subSubMacdMap.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
subSubMarkers.push({ time: ts, position: posCd, color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
subSubMarkers.push(makeCdMarker(ts, posCd));
}
if (item.near0_return && Number(item.near0_return) > 0) {
subSubMarkers.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
@@ -484,7 +527,8 @@ function chartTvRenderIndicators(ctx) {
const k = klineArr[i];
if (!k || !k.date) continue;
const t = Math.floor(new Date(k.date).getTime() / 1000);
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
const hist = (macdObj.histogram && macdObj.histogram[i] !== undefined && macdObj.histogram[i] !== null) ? macdObj.histogram[i] : null;
const val = (hist !== null) ? hist : ((macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null);
map.set(t, val);
}
return map;
@@ -494,20 +538,24 @@ function chartTvRenderIndicators(ctx) {
(currentData.element_macd || currentData.macd),
(currentData.element_kline_data || currentData.kline_data)
);
const subSubMacdMapAll = buildMacdTimeMapAll(
(currentData.sub_sub_macd || currentData.macd),
(currentData.sub_sub_kline_data || currentData.kline_data)
);
if ((typeof window.showUOnMain === 'undefined' ? false : window.showUOnMain) && Array.isArray(mainCmAll.klu_list)) {
mainCmAll.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
if (shouldShowSdMarker(item.separate_div)) {
const macdVal = mainMacdMapAll.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
mainMarkersAll.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
mainMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div));
}
if (item.continue_div === true) {
const macdVal = mainMacdMapAll.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
mainMarkersAll.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
mainMarkersAll.push(makeCdMarker(ts, posCd));
}
if (item.near0_return && Number(item.near0_return) > 0) {
mainMarkersAll.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
@@ -519,15 +567,15 @@ function chartTvRenderIndicators(ctx) {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
if (shouldShowSdMarker(item.separate_div)) {
const macdVal = elementMacdMapAll.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
elementMarkersAll.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
elementMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div));
}
if (item.continue_div === true) {
const macdVal = elementMacdMapAll.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
elementMarkersAll.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
elementMarkersAll.push(makeCdMarker(ts, posCd));
}
if (item.near0_return && Number(item.near0_return) > 0) {
elementMarkersAll.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
@@ -543,11 +591,15 @@ function chartTvRenderIndicators(ctx) {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
subSubMarkersAll.push({ time: ts, position: 'aboveBar', color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
if (shouldShowSdMarker(item.separate_div)) {
const macdVal = subSubMacdMapAll.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
subSubMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div));
}
if (item.continue_div === true) {
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
const macdVal = subSubMacdMapAll.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
subSubMarkersAll.push(makeCdMarker(ts, posCd));
}
if (item.near0_return && Number(item.near0_return) > 0) {
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
@@ -555,6 +607,9 @@ function chartTvRenderIndicators(ctx) {
});
}
window.kluDivMarkersSubSub = subSubMarkersAll;
if (typeof refreshUnittfOverlayFromData === 'function') {
refreshUnittfOverlayFromData(currentData);
}
} catch (e) {
console.warn('独立计算 KLU 背驰标记出错:', e);
window.kluDivMarkersMain = [];
+1 -1
View File
@@ -25,7 +25,7 @@ function disposeTradingViewCharts() {
}
if (tvWidget) {
['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart'].forEach(function (key) {
['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart', 'sentimentChart'].forEach(function (key) {
try {
if (tvWidget[key] && typeof tvWidget[key].remove === 'function') {
tvWidget[key].remove();
+173 -81
View File
@@ -82,6 +82,97 @@ var SUB_SUB_KLC_TREND_STYLE = {
UNKNOWN: { position: 'inBar', color: '#004d40', shape: 'square', size: 0.5 }
};
function pushAreaTextLabel(out, item, style, text) {
if (!item || !item.end_time || !text) return;
var ts = Math.floor(new Date(item.end_time).getTime() / 1000);
if (isNaN(ts)) return;
var price = Number(item.end_price);
if (!isFinite(price)) price = Number(item.start_price);
if (!isFinite(price)) return;
var up = Number(item.direction) === 1;
out.push({
time: ts,
price: price,
text: text,
color: style.color,
above: up
});
}
function formatMacdAreaText(v) {
var n = Number(v);
if (!isFinite(n) || n === 0) return '';
var abs = Math.abs(n);
if (abs >= 100) return n.toFixed(0);
if (abs >= 10) return n.toFixed(1);
return n.toFixed(2);
}
function pushAreaHistMarker(out, item, style) {
pushAreaTextLabel(out, item, style, formatMacdAreaText(item && item.macd_hist));
}
function collectAreaHistMarkers(biList, uncompletedBi, segList, uncompletedSeg, biStyle, segStyle, showBi, showSeg) {
var out = [];
if (showBi) {
(biList || []).forEach(function (bi) { pushAreaHistMarker(out, bi, biStyle); });
(uncompletedBi || []).forEach(function (bi) { pushAreaHistMarker(out, bi, biStyle); });
}
if (showSeg) {
(segList || []).forEach(function (seg) { pushAreaHistMarker(out, seg, segStyle); });
(uncompletedSeg || []).forEach(function (seg) { pushAreaHistMarker(out, seg, segStyle); });
}
return out;
}
function buildAreaHistMarkersFromData(data) {
var markers = [];
if (!data) return markers;
var showMainBi = $('#showMainBiArea').is(':checked');
var showMainSeg = $('#showMainSegArea').is(':checked');
if (showMainBi || showMainSeg) {
markers = markers.concat(collectAreaHistMarkers(
data.bi_list,
data.uncompleted_bi_list,
data.seg_list,
data.uncompleted_seg_list,
{ color: '#1565c0', size: 0.55 },
{ color: '#00838f', size: 0.65 },
showMainBi,
showMainSeg
));
}
var showElementBi = $('#showElementBiArea').is(':checked');
var showElementSeg = $('#showElementSegArea').is(':checked');
if (showElementBi || showElementSeg) {
markers = markers.concat(collectAreaHistMarkers(
data.element_bi_list,
data.element_uncompleted_bi_list,
data.element_seg_list,
data.element_uncompleted_seg_list,
{ color: '#3949ab', size: 0.5 },
{ color: '#5c6bc0', size: 0.6 },
showElementBi,
showElementSeg
));
}
var showSubSubBi = $('#showSubSubBiArea').is(':checked');
var showSubSubSeg = $('#showSubSubSegArea').is(':checked');
if (showSubSubBi || showSubSubSeg) {
markers = markers.concat(collectAreaHistMarkers(
data.sub_sub_bi_list,
data.sub_sub_uncompleted_bi_list,
data.sub_sub_seg_list,
data.sub_sub_uncompleted_seg_list,
{ color: '#2e7d32', size: 0.45 },
{ color: '#558b2f', size: 0.55 },
showSubSubBi,
showSubSubSeg
));
}
return markers;
}
function buildKlcTrendMarker(timeAligned, trendRaw, palette) {
var kind = normalizeKlcTrendRaw(trendRaw);
var style = palette[kind] || palette.UNKNOWN || palette.FLAT;
@@ -136,6 +227,21 @@ function getMainPriceSeries() {
s.lineSeries || s.areaSeries || s.baselineSeries || null;
}
function plotLeftOffset(chart) {
try {
var left = chart && chart.priceScale && chart.priceScale('left');
if (!left) return 0;
var visible = true;
try {
var opts = left.options && left.options();
if (opts && opts.visible === false) return 0;
} catch (e) {}
return (typeof left.width === 'function' ? left.width() : 0) || 0;
} catch (e) {
return 0;
}
}
function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
if (!mainChart || !mainChartContainer) return;
if (typeof window._fxBoxOverlayCleanup === 'function') {
@@ -163,23 +269,38 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
// LWC 4 无 priceScale 订阅:采样坐标变化(含增量 setData 后自动缩放)
var sampleSig = function () {
var boxes = window._fxBoxVerticals || [];
var labels = window._areaTextLabels || [];
var series = getMainPriceSeries();
if (!series || !boxes.length) return '0';
if (!series || (!boxes.length && !labels.length)) return '0';
var ts = mainChart.timeScale();
var a = boxes[0];
var b = boxes[boxes.length - 1];
return [
boxes.length,
quant(ts.timeToCoordinate(a.time)),
quant(series.priceToCoordinate(a.hi)),
quant(series.priceToCoordinate(a.lo)),
quant(ts.timeToCoordinate(b.time)),
quant(series.priceToCoordinate(b.hi)),
quant(series.priceToCoordinate(b.lo))
].join('|');
var parts = [boxes.length, labels.length, quant(plotLeftOffset(mainChart))];
if (boxes.length) {
var a = boxes[0];
var b = boxes[boxes.length - 1];
parts.push(
quant(ts.timeToCoordinate(a.time)),
quant(series.priceToCoordinate(a.hi)),
quant(series.priceToCoordinate(a.lo)),
quant(ts.timeToCoordinate(b.time)),
quant(series.priceToCoordinate(b.hi)),
quant(series.priceToCoordinate(b.lo))
);
}
if (labels.length) {
var la = labels[0];
var lb = labels[labels.length - 1];
parts.push(
quant(ts.timeToCoordinate(la.time)),
quant(series.priceToCoordinate(la.price)),
quant(ts.timeToCoordinate(lb.time)),
quant(series.priceToCoordinate(lb.price))
);
}
return parts.join('|');
};
var redraw = function () {
var boxes = window._fxBoxVerticals || [];
var labels = window._areaTextLabels || [];
var series = getMainPriceSeries();
var rect = mainChartContainer.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
@@ -191,26 +312,41 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
if (!ctx2) return;
ctx2.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx2.clearRect(0, 0, rect.width, rect.height);
if (!series || !boxes.length) {
if (!series || (!boxes.length && !labels.length)) {
lastSig = sampleSig();
return;
}
var ts = mainChart.timeScale();
var x0 = plotLeftOffset(mainChart);
for (var i = 0; i < boxes.length; i++) {
var box = boxes[i];
var x = ts.timeToCoordinate(box.time);
var y1 = series.priceToCoordinate(box.hi);
var y2 = series.priceToCoordinate(box.lo);
if (x == null || y1 == null || y2 == null) continue;
var px = Math.round(x + x0) + 0.5;
ctx2.beginPath();
ctx2.strokeStyle = box.color;
ctx2.lineWidth = 1;
ctx2.setLineDash([4, 3]);
ctx2.moveTo(Math.round(x) + 0.5, y1);
ctx2.lineTo(Math.round(x) + 0.5, y2);
ctx2.moveTo(px, y1);
ctx2.lineTo(px, y2);
ctx2.stroke();
}
ctx2.setLineDash([]);
if (labels.length) {
ctx2.font = '11px sans-serif';
ctx2.textAlign = 'center';
for (var li = 0; li < labels.length; li++) {
var lab = labels[li];
var lx = ts.timeToCoordinate(lab.time);
var ly = series.priceToCoordinate(lab.price);
if (lx == null || ly == null) continue;
ctx2.fillStyle = lab.color;
ctx2.textBaseline = lab.above ? 'bottom' : 'top';
ctx2.fillText(lab.text, Math.round(lx + x0), lab.above ? ly - 3 : ly + 3);
}
}
lastSig = sampleSig();
};
var scheduleRedraw = function () {
@@ -257,6 +393,7 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
function chartTvRenderOverlays(ctx) {
window._fxBoxVerticals = [];
window._areaTextLabels = [];
var symbol = ctx.symbol;
var timeframe = ctx.timeframe;
var symbolConfig = ctx.symbolConfig;
@@ -990,56 +1127,6 @@ function chartTvRenderOverlays(ctx) {
window.bspMarkers = [];
}
// 第四类买卖点(B4/S4):中枢突破回抽后当根入场,位置同 B3/S3 但早 7~8 根。
// 与 BSP 分开收集,因为它数量远多于 B1/B2/B3,混在一个开关里图会糊掉。
if ($('#showMainFastBsp').is(':checked') || $('#showElementFastBsp').is(':checked') || $('#showSubSubFastBsp').is(':checked')) {
// 深色 = 区间套(大级别分型同向) + 中枢顺向推进都满足;浅色 = 未通过过滤
const FAST_BSP_STYLE = {
'BUY': { strong: '#FF6D00', weak: '#FFCC80', text: 'B4', position: 'belowBar' },
'SELL': { strong: '#0091EA', weak: '#81D4FA', text: 'S4', position: 'aboveBar' },
};
const onlyFiltered = ($('#fastBspFilterMode').val() || 'all') === 'filtered';
const allFastBspMarkers = [];
const collectFastBsp = function(list, prefix, label) {
(list || []).forEach(function(bsp) {
try {
const ts = Math.floor(new Date(bsp.time).getTime() / 1000);
if (isNaN(ts)) return;
const style = FAST_BSP_STYLE[(bsp.dir || '').toUpperCase()];
if (!style) return;
const passed = !!(bsp.htf_agree && bsp.ladder_ok);
if (onlyFiltered && !passed) return;
allFastBspMarkers.push({
time: ts,
position: style.position,
color: passed ? style.strong : style.weak,
text: prefix + (passed ? style.text : style.text.toLowerCase()),
size: passed ? 2 : 1
});
} catch (e) {
console.error(label + '第四类买卖点处理出错:', e);
}
});
};
if ($('#showMainFastBsp').is(':checked')) {
collectFastBsp(currentData.fast_bsp_list, '', '主周期');
}
if ($('#showElementFastBsp').is(':checked')) {
collectFastBsp(currentData.element_fast_bsp_list, 'e', '次周期');
}
if ($('#showSubSubFastBsp').is(':checked')) {
collectFastBsp(currentData.sub_sub_fast_bsp_list, 's', '次次周期');
}
allFastBspMarkers.sort((a, b) => a.time - b.time);
window.fastBspMarkers = allFastBspMarkers;
console.log(`绘制第四类买卖点,共${allFastBspMarkers.length}个标记(${onlyFiltered ? '仅过滤后' : '全部'}`);
} else {
window.fastBspMarkers = [];
}
// 添加买卖点标记(旧版,保留兼容)
// 这里为了与主面板上的「买卖点」开关保持一致,
// 同时响应顶部的 `#showMainBsp` 复选框
@@ -1615,7 +1702,7 @@ function chartTvRenderOverlays(ctx) {
lineStyle: 2, // 虚线
lastValueVisible: false,
priceLineVisible: false,
title: '次周期布林上轨'
title: '次布林上轨'
});
elementUpperBandSeries.setData(elementUpperBandData);
@@ -1626,7 +1713,7 @@ function chartTvRenderOverlays(ctx) {
lineStyle: 2, // 虚线
lastValueVisible: false,
priceLineVisible: false,
title: '次周期布林下轨'
title: '次布林下轨'
});
elementLowerBandSeries.setData(elementLowerBandData);
@@ -1636,7 +1723,7 @@ function chartTvRenderOverlays(ctx) {
lineWidth: 1,
lastValueVisible: false,
priceLineVisible: false,
title: '次周期布林中轨'
title: '次布林中轨'
});
elementMiddleBandSeries.setData(elementMiddleBandData);
@@ -1751,7 +1838,7 @@ function chartTvRenderOverlays(ctx) {
const fxMarker = {
time: timestamp,
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
周期${fx.is_bottom ? '底分型' : '顶分型'}(): ${fx.fx_type}<br>
主${fx.is_bottom ? '底分型' : '顶分型'}(): ${fx.fx_type}<br>
强度分数: ${fx.fx_strength}<br>
强度等级: ${fx.fx_strength_level}<br>
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
@@ -1815,7 +1902,7 @@ function chartTvRenderOverlays(ctx) {
const fxMarker = {
time: timestamp,
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
周期${fx.is_bottom ? '底分型' : '顶分型'}(): ${fx.fx_type}<br>
主${fx.is_bottom ? '底分型' : '顶分型'}(): ${fx.fx_type}<br>
强度分数: ${fx.fx_strength}<br>
强度等级: ${fx.fx_strength_level}<br>
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
@@ -1852,6 +1939,14 @@ function chartTvRenderOverlays(ctx) {
window.mainFxMarkers = [];
window.fxMarkers = [];
}
window._areaTextLabels = alignMarkersToCandles(
buildAreaHistMarkersFromData(currentData),
candles
);
if (typeof window._redrawFxBoxVerticalOverlay === 'function') {
window._redrawFxBoxVerticalOverlay();
}
// 绘制小周期分型标记(含次次周期)
if (($('#showElementKlcFxType').is(':checked') && currentData.element_klc_fx_info && currentData.element_klc_fx_info.length > 0) ||
($('#showElementKluFxType').is(':checked') && currentData.element_klu_fx_info && currentData.element_klu_fx_info.length > 0) ||
@@ -1940,7 +2035,7 @@ function chartTvRenderOverlays(ctx) {
const elementFxMarker = {
time: timestamp,
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
小周期${fx.is_bottom ? '底分型' : '顶分型'}(): ${fx.fx_type}<br>
${fx.is_bottom ? '底分型' : '顶分型'}(): ${fx.fx_type}<br>
强度分数: ${fx.fx_strength}<br>
强度等级: ${fx.fx_strength_level}<br>
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
@@ -1996,7 +2091,7 @@ function chartTvRenderOverlays(ctx) {
const elementFxMarker = {
time: timestamp,
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
小周期${fx.is_bottom ? '底分型' : '顶分型'}(): ${fx.fx_type}<br>
${fx.is_bottom ? '底分型' : '顶分型'}(): ${fx.fx_type}<br>
强度分数: ${fx.fx_strength}<br>
强度等级: ${fx.fx_strength_level}<br>
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
@@ -2157,7 +2252,7 @@ function chartTvRenderOverlays(ctx) {
trendMarkersToUse = trendMarkersToUse.concat(subSubMarkers);
}
// 合并标记并设置
// 合并标记并设置(主图不画 U/穿零轴,只留背驰 SD/CD)
const combinedMarkers = [
...(window.mainFxMarkers || []),
...allElementFxMarkers,
@@ -2165,17 +2260,15 @@ function chartTvRenderOverlays(ctx) {
...(window.kluDivMarkersElement || []),
...(window.kluDivMarkersSubSub || []),
...trendMarkersToUse,
...(window.bspMarkers || []),
...(window.fastBspMarkers || [])
...(window.bspMarkers || [])
];
if (combinedMarkers.length > 0) {
console.log(
'合并设置', combinedMarkers.length, '个标记(主周期分型:',
(window.mainFxMarkers || []).length,
'个,小周期分型:', allElementFxMarkers.length,
'个,UnitTF:', (window.unittfMarkers || []).length,
'个,背驰:', (window.kluDivMarkersMain || []).length,
'个,BSP标记:', (window.bspMarkers || []).length,
'个,第四类标记:', (window.fastBspMarkers || []).length,
'个)'
);
@@ -2277,18 +2370,17 @@ function chartTvRenderOverlays(ctx) {
// 这里的 onlyMainAndU 实际上是「最终要挂到主K线上」的一组标记
// 之前没有把 window.bspMarkers 合进去,导致上面已经合并了 BSP 标记,
// 但在这里再次调用 setMarkers 时把 BSP 覆盖掉了,从而前端看不到买卖点。
// 修复:把 BSP 标记一并合并进来。第四类买卖点同理,两处都要带上。
// 修复:把 BSP 标记一并合并进来。两处都要带上,否则后面这次 setMarkers 会把买卖点盖掉
const onlyMainAndU = [
...(window.mainFxMarkers || []),
...(window.kluDivMarkersMain || []),
...(window.kluDivMarkersElement || []),
...(window.kluDivMarkersSubSub || []),
...trendMarkersToUse,
...(window.bspMarkers || []),
...(window.fastBspMarkers || [])
...(window.bspMarkers || [])
];
if (onlyMainAndU.length > 0) {
console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, 'UnitTF:', (window.unittfMarkers || []).length, '');
console.log('仅设置', onlyMainAndU.length, '个主标记(主周期分型:', (window.mainFxMarkers || []).length, '背驰:', (window.kluDivMarkersMain || []).length, '');
// 根据当前主系列类型设置标记
const klineType2 = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
+95 -26
View File
@@ -77,6 +77,7 @@ function chartTvBuildShell(ctx) {
mainChart: null,
volumeChart: null,
macdChart: null,
sentimentChart: null,
series: {
candleSeries: null,
lineSeries: null,
@@ -125,6 +126,7 @@ function chartTvBuildShell(ctx) {
// 是否显示MACD
const showMacd = $('#showMacd').is(':checked');
const showOriginalKline = $('#showOriginalKline').is(':checked');
const showSentiment = ($('#dataSource').val() || 'crypto') === 'crypto' && $('#showDeriv').is(':checked');
// 创建主图容器
const mainChartContainer = document.createElement('div');
@@ -153,25 +155,20 @@ function chartTvBuildShell(ctx) {
// 如果需要显示MACD,创建MACD容器
let macdChartContainer = null;
let chanMacdChartContainer = null;
if (showMacd) {
// 仅显示新的 ChanMACD 图:让其占用原 MACD+ChanMACD 的整体高度
// 新布局:主图(40%) → ChanMACD(30%) → 成交量(17.5%) → ATR(12.5%)
mainChartContainer.style.height = '40%';
// 隐藏旧 MACD 容器(不创建)
// 创建 ChanMACD 容器占据原 MACD+ChanMACD 高度(30%
let sentimentChartContainer = null;
if (showMacd && showSentiment) {
mainChartContainer.style.height = '34%';
chanMacdChartContainer = document.createElement('div');
chanMacdChartContainer.style.width = '100%';
chanMacdChartContainer.style.height = '30%';
chanMacdChartContainer.style.height = 'calc(22% - 50px)';
chanMacdChartContainer.style.position = 'absolute';
chanMacdChartContainer.style.top = '40%';
chanMacdChartContainer.style.top = '34%';
chanMacdChartContainer.style.left = '0';
chanMacdChartContainer.style.right = '0';
chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0';
chanMacdChartContainer.style.zIndex = '10';
// 水印:便于区分是新的 ChanMACD 子图
const chanMacdWatermark = document.createElement('div');
chanMacdWatermark.textContent = 'ChanMACD';
chanMacdWatermark.textContent = 'MACD';
chanMacdWatermark.style.position = 'absolute';
chanMacdWatermark.style.top = '4px';
chanMacdWatermark.style.left = '8px';
@@ -179,31 +176,81 @@ function chartTvBuildShell(ctx) {
chanMacdWatermark.style.color = '#888';
chanMacdWatermark.style.pointerEvents = 'none';
chanMacdChartContainer.appendChild(chanMacdWatermark);
// 成交量位于 ChanMACD 之下
volumeChartContainer.style.top = '70%';
volumeChartContainer.style.height = '17.5%';
// ATR 位于最底部
atrChartContainer.style.top = '87.5%';
atrChartContainer.style.height = '12.5%';
volumeChartContainer.style.top = 'calc(56% - 50px)';
volumeChartContainer.style.height = 'calc(12% + 20px)';
atrChartContainer.style.top = 'calc(68% - 30px)';
atrChartContainer.style.height = 'calc(10% - 20px)';
} else if (showMacd) {
mainChartContainer.style.height = '40%';
chanMacdChartContainer = document.createElement('div');
chanMacdChartContainer.style.width = '100%';
chanMacdChartContainer.style.height = 'calc(30% - 50px)';
chanMacdChartContainer.style.position = 'absolute';
chanMacdChartContainer.style.top = '40%';
chanMacdChartContainer.style.left = '0';
chanMacdChartContainer.style.right = '0';
chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0';
chanMacdChartContainer.style.zIndex = '10';
const chanMacdWatermark = document.createElement('div');
chanMacdWatermark.textContent = 'MACD';
chanMacdWatermark.style.position = 'absolute';
chanMacdWatermark.style.top = '4px';
chanMacdWatermark.style.left = '8px';
chanMacdWatermark.style.fontSize = '11px';
chanMacdWatermark.style.color = '#888';
chanMacdWatermark.style.pointerEvents = 'none';
chanMacdChartContainer.appendChild(chanMacdWatermark);
volumeChartContainer.style.top = 'calc(70% - 50px)';
volumeChartContainer.style.height = 'calc(17.5% + 20px)';
atrChartContainer.style.top = 'calc(87.5% - 30px)';
atrChartContainer.style.height = 'calc(12.5% - 20px)';
} else if (showSentiment) {
mainChartContainer.style.height = '48%';
volumeChartContainer.style.top = '48%';
volumeChartContainer.style.height = '14%';
atrChartContainer.style.top = '62%';
atrChartContainer.style.height = '12%';
} else {
// 不显示MACD时的高度 - 主图、成交量图和ATR图分配
mainChartContainer.style.height = '55%'; // 主图占55%
mainChartContainer.style.height = '55%';
volumeChartContainer.style.top = '55%';
volumeChartContainer.style.height = '22.5%'; // 成交量图占22.5%
atrChartContainer.style.top = '77.5%'; // ATR图从77.5%位置开始
atrChartContainer.style.height = '22.5%'; // ATR图占22.5%
volumeChartContainer.style.height = '22.5%';
atrChartContainer.style.top = '77.5%';
atrChartContainer.style.height = '22.5%';
}
if (showSentiment) {
sentimentChartContainer = document.createElement('div');
sentimentChartContainer.style.width = '100%';
sentimentChartContainer.style.position = 'absolute';
sentimentChartContainer.style.left = '0';
sentimentChartContainer.style.right = '0';
sentimentChartContainer.style.borderTop = '1px solid #e0e0e0';
if (showMacd) {
sentimentChartContainer.style.top = '78%';
sentimentChartContainer.style.height = '22%';
} else {
sentimentChartContainer.style.top = '74%';
sentimentChartContainer.style.height = '26%';
}
const sentimentWatermark = document.createElement('div');
sentimentWatermark.textContent = '衍生品 买卖比 / 多空 / 大户 / 费率';
sentimentWatermark.style.position = 'absolute';
sentimentWatermark.style.top = '4px';
sentimentWatermark.style.left = '8px';
sentimentWatermark.style.fontSize = '11px';
sentimentWatermark.style.color = '#888';
sentimentWatermark.style.pointerEvents = 'none';
sentimentChartContainer.appendChild(sentimentWatermark);
}
container.appendChild(mainChartContainer);
container.appendChild(volumeChartContainer);
container.appendChild(atrChartContainer);
if (showMacd) {
// 只追加新的 ChanMACD 容器
container.appendChild(chanMacdChartContainer);
}
if (showSentiment && sentimentChartContainer) {
container.appendChild(sentimentChartContainer);
}
// 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责)
@@ -221,6 +268,8 @@ function chartTvBuildShell(ctx) {
chartHeight = macdChartContainer ? macdChartContainer.clientHeight : 0;
} else if (chartType === 'chanmacd') {
chartHeight = chanMacdChartContainer ? chanMacdChartContainer.clientHeight : 0;
} else if (chartType === 'sentiment') {
chartHeight = sentimentChartContainer ? sentimentChartContainer.clientHeight : 0;
} else {
chartHeight = mainChartContainer.clientHeight;
}
@@ -376,9 +425,26 @@ function chartTvBuildShell(ctx) {
// 创建MACD图表(如果需要):仅创建新的 ChanMACD 图
let macdChart = null;
let chanMacdChart = null;
let sentimentChart = null;
if (showMacd) {
chanMacdChart = LightweightCharts.createChart(chanMacdChartContainer, createChartOptions(false, 'chanmacd'));
}
if (showSentiment && sentimentChartContainer) {
sentimentChart = LightweightCharts.createChart(sentimentChartContainer, createChartOptions(false, 'sentiment'));
if (candles.length) {
const axisSeries = sentimentChart.addLineSeries({
priceScaleId: '__time',
color: 'rgba(0,0,0,0)',
lineWidth: 0,
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false
});
sentimentChart.priceScale('__time').applyOptions({ visible: false });
axisSeries.setData(candles.map(function (c) { return { time: c.time, value: 0 }; }));
tvWidget.series.sentimentAxisSeries = axisSeries;
}
}
// 创建主价格系列并设置数据(支持多种图表类型)
(function(){
@@ -495,10 +561,13 @@ function chartTvBuildShell(ctx) {
ctx.atrChartContainer = atrChartContainer;
ctx.macdChartContainer = macdChartContainer;
ctx.chanMacdChartContainer = chanMacdChartContainer;
ctx.sentimentChartContainer = sentimentChartContainer;
ctx.showSentiment = showSentiment;
ctx.mainChart = mainChart;
ctx.volumeChart = volumeChart;
ctx.atrChart = atrChart;
ctx.macdChart = macdChart;
ctx.chanMacdChart = chanMacdChart;
ctx.sentimentChart = sentimentChart;
ctx.createChartOptions = createChartOptions;
}
+3 -3
View File
@@ -74,10 +74,10 @@ function readChartFormContext() {
return {
dataSource: dataSource,
symbol: symbol,
timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h',
timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '45m',
timezone: $('#timezone').val() || 'Asia/Shanghai',
elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m',
subSubTimeframe: $('#subSubTimeframe').val() || '',
elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '15m',
subSubTimeframe: $('#subSubTimeframe').val() || window.DEFAULT_SUB_SUB_TIMEFRAME || '5m',
startTimeMs: $('#start_time').val() ? new Date($('#start_time').val()).getTime() : null,
endTimeMs: $('#end_time').val() ? new Date($('#end_time').val()).getTime() : null
};
+436
View File
@@ -0,0 +1,436 @@
/* 资金面 + 情绪面。只打本站中转。OI 叠主图左侧,其余叠情绪副图。 */
window.ChanDeriv = (function () {
var snapshotReq = 0;
var latestReq = 0;
var overlayReq = 0;
var caches = {};
var METRICS = [
{
id: 'oi',
checkbox: 'showOi',
metric: 'open_interest_history',
field: 'open_interest_amount',
seriesKey: 'oiSeries',
target: 'main',
color: 'rgba(123, 31, 162, 0.85)',
title: 'OI',
priceFormat: { type: 'volume' }
},
{
id: 'taker',
checkbox: 'showDeriv',
metric: 'taker_buy_sell_ratio',
field: 'buy_sell_ratio',
seriesKey: 'takerSeries',
target: 'sentiment',
color: '#0d9488',
title: '买卖比',
priceFormat: { type: 'price', precision: 3 }
},
{
id: 'lsAccount',
checkbox: 'showDeriv',
metric: 'long_short_account_ratio',
field: 'long_short_ratio',
seriesKey: 'lsAccountSeries',
target: 'sentiment',
color: '#2563eb',
title: '多空',
priceFormat: { type: 'price', precision: 3 }
},
{
id: 'lsTop',
checkbox: 'showDeriv',
metric: 'top_long_short_position_ratio',
field: 'long_short_ratio',
seriesKey: 'lsTopSeries',
target: 'sentiment',
color: '#ea580c',
title: '大户',
priceFormat: { type: 'price', precision: 3 }
},
{
id: 'funding',
checkbox: 'showDeriv',
metric: 'funding_rate_history',
field: 'funding_rate',
seriesKey: 'fundingHistSeries',
target: 'sentiment',
color: '#7c3aed',
title: '费率%',
scale: 'funding',
mul: 100,
priceFormat: { type: 'price', precision: 4 }
}
];
function isCrypto() {
return ($('#dataSource').val() || 'crypto') === 'crypto';
}
function currentSymbol() {
return $('#symbol').val() || 'BTC/USDT:USDT';
}
function fmtOi(n) {
if (n == null || !isFinite(Number(n))) return '—';
return Number(n).toLocaleString('en-US', { maximumFractionDigits: 1 });
}
function fmtFunding(n) {
if (n == null || !isFinite(Number(n))) return '—';
return (Number(n) * 100).toFixed(4) + '%';
}
function fmtChg(n) {
if (n == null || !isFinite(Number(n))) return '—';
var v = Number(n);
return (v > 0 ? '+' : '') + v.toFixed(2) + '%';
}
function fmtBasis(n) {
if (n == null || !isFinite(Number(n))) return '—';
var v = Number(n);
return (v > 0 ? '+' : '') + v.toFixed(2);
}
function fmtRatio(n) {
if (n == null || !isFinite(Number(n))) return '—';
return Number(n).toFixed(3);
}
function setChip(id, text, tone) {
var el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.classList.remove('up', 'down');
if (tone) el.classList.add(tone);
}
function tone(n, invert) {
if (!isFinite(n) || n === 0) return null;
var up = n > 0;
if (invert) up = !up;
return up ? 'up' : 'down';
}
function renderDerivEmpty() {
setChip('derivOi', 'OI —');
setChip('derivOiChg', 'Δ —');
setChip('derivFunding', '费率 —');
setChip('derivBasis', '基差 —');
var src = document.getElementById('derivSrc');
if (src) src.textContent = '';
}
function renderSentimentEmpty() {
setChip('derivTaker', '买卖比 —');
setChip('derivLs', '多空 —');
setChip('derivTop', '大户 —');
}
function setVisible(on) {
var bar = document.getElementById('derivBar');
var wrap = document.getElementById('showSentimentWrap');
if (bar) bar.style.display = on ? '' : 'none';
if (wrap) wrap.style.display = on ? '' : 'none';
if (!on) clearAllSeries();
}
function activeKlines() {
var data = (typeof currentData !== 'undefined') ? currentData : null;
if (!data) return [];
if ($('#subSubPeriodKline').is(':checked') && data.sub_sub_kline_data) return data.sub_sub_kline_data;
if ($('#elementPeriodKline').is(':checked') && data.element_kline_data) return data.element_kline_data;
return data.kline_data || [];
}
function klineRangeMs() {
var rows = activeKlines();
if (!rows.length) return null;
var start = new Date(rows[0].date).getTime();
var end = new Date(rows[rows.length - 1].date).getTime();
if (!isFinite(start) || !isFinite(end)) return null;
return { start: start, end: end };
}
function toPoints(rows, field, mul) {
var out = [];
var lastT = null;
var factor = mul || 1;
(rows || []).forEach(function (row) {
var ms = Number(row.timestamp);
var val = Number(row[field]);
if (!isFinite(ms) || !isFinite(val)) return;
var t = Math.floor(ms / 1000);
var v = val * factor;
if (lastT === t) {
out[out.length - 1].value = v;
return;
}
lastT = t;
out.push({ time: t, value: v });
});
return out;
}
function candleTimesSec() {
var rows = activeKlines();
var times = [];
(rows || []).forEach(function (k) {
var t = Math.floor(new Date(k.date).getTime() / 1000);
if (isFinite(t)) times.push(t);
});
return times;
}
function alignToTimes(src, times) {
if (!src || !src.length || !times || !times.length) return [];
var out = [];
var j = 0;
var lastVal;
for (var i = 0; i < times.length; i++) {
var t = times[i];
while (j < src.length && src[j].time <= t) {
lastVal = src[j].value;
j++;
}
if (lastVal !== undefined) out.push({ time: t, value: lastVal });
}
return out;
}
function syncSentimentTime() {
if (!tvWidget || !tvWidget.mainChart || !tvWidget.sentimentChart) return;
try {
var vr = tvWidget.mainChart.timeScale().getVisibleRange();
var lr = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
var opts = tvWidget.mainChart.timeScale().options ? tvWidget.mainChart.timeScale().options() : null;
if (opts) {
tvWidget.sentimentChart.timeScale().applyOptions({
barSpacing: opts.barSpacing,
rightOffset: opts.rightOffset
});
}
if (vr) tvWidget.sentimentChart.timeScale().setVisibleRange(vr);
if (lr) tvWidget.sentimentChart.timeScale().setVisibleLogicalRange(lr);
} catch (e) {}
}
function targetChart(spec) {
if (!tvWidget) return null;
if (spec.target === 'main') return tvWidget.mainChart;
return tvWidget.sentimentChart || null;
}
function safeRemove(chart, seriesKey) {
var series = tvWidget && tvWidget.series && tvWidget.series[seriesKey];
if (series && chart) {
try { chart.removeSeries(series); } catch (e) {}
}
if (tvWidget && tvWidget.series) tvWidget.series[seriesKey] = null;
}
function clearAllSeries() {
METRICS.forEach(function (spec) {
safeRemove(targetChart(spec), spec.seriesKey);
});
safeRemove(tvWidget && tvWidget.sentimentChart, 'sentimentBaseSeries');
try {
if (tvWidget && tvWidget.mainChart) {
tvWidget.mainChart.applyOptions({ leftPriceScale: { visible: false } });
}
} catch (e) {}
}
function drawSeries(spec, points) {
var chart = targetChart(spec);
if (!chart || !points || !points.length) return;
safeRemove(chart, spec.seriesKey);
if (spec.target === 'main') {
chart.applyOptions({
leftPriceScale: {
visible: true,
borderVisible: false,
scaleMargins: { top: 0.08, bottom: 0.12 }
}
});
}
var opts = {
color: spec.color,
lineWidth: 1,
title: spec.title,
lastValueVisible: true,
priceLineVisible: false,
priceFormat: spec.priceFormat
};
if (spec.target === 'main') opts.priceScaleId = 'left';
if (spec.scale) {
opts.priceScaleId = spec.scale;
chart.priceScale(spec.scale).applyOptions({
scaleMargins: { top: 0.15, bottom: 0.1 },
borderVisible: false
});
}
var series = chart.addLineSeries(opts);
series.setData(points);
tvWidget.series[spec.seriesKey] = series;
if (spec.target === 'sentiment') syncSentimentTime();
if (spec.target === 'main' && typeof window._redrawFxBoxVerticalOverlay === 'function') {
window._redrawFxBoxVerticalOverlay();
setTimeout(window._redrawFxBoxVerticalOverlay, 50);
}
}
function drawRatioBaseline(points) {
var chart = tvWidget && tvWidget.sentimentChart;
if (!chart || !points || !points.length) return;
safeRemove(chart, 'sentimentBaseSeries');
var baseline = points.map(function (p) { return { time: p.time, value: 1 }; });
var series = chart.addLineSeries({
color: 'rgba(120, 120, 120, 0.45)',
lineWidth: 1,
lineStyle: 2,
lastValueVisible: false,
priceLineVisible: false,
title: '1.0'
});
series.setData(baseline);
tvWidget.series.sentimentBaseSeries = series;
}
function loadOne(spec, range, symbol, req) {
var checked = $('#' + spec.checkbox).is(':checked');
var chart = targetChart(spec);
if (!checked) {
safeRemove(chart, spec.seriesKey);
if (spec.id === 'oi') {
try {
if (tvWidget && tvWidget.mainChart) {
tvWidget.mainChart.applyOptions({ leftPriceScale: { visible: false } });
}
} catch (e) {}
if (typeof window._redrawFxBoxVerticalOverlay === 'function') {
window._redrawFxBoxVerticalOverlay();
}
}
return;
}
if (!chart) return;
var times = candleTimesSec();
var key = [symbol, spec.metric, times[0] || '', times[times.length - 1] || '', times.length].join(':');
var cached = caches[spec.id];
function paint(raw) {
var aligned = spec.target === 'main' || spec.target === 'sentiment' ? alignToTimes(raw, times) : raw;
if (!aligned.length) aligned = raw;
drawSeries(spec, aligned);
if (spec.target === 'sentiment') maybeDrawBaseline();
}
if (cached && cached.raw && cached.raw.length) {
paint(cached.raw);
if (cached.key === key) return;
}
if (!range || !window.ChanApi || !ChanApi.sentimentMetrics) return;
ChanApi.sentimentMetrics({
metric: spec.metric,
symbol: symbol,
start: range.start - 8 * 60 * 60 * 1000,
end: range.end,
limit: 2000
}).then(function (payload) {
if (req !== overlayReq) return;
var raw = toPoints(payload && payload.data, spec.field, spec.mul);
if (!raw.length) return;
caches[spec.id] = { key: key, raw: raw };
if ($('#' + spec.checkbox).is(':checked') && targetChart(spec)) paint(raw);
}).catch(function (err) {
if (req !== overlayReq) return;
console.warn(spec.title + ' 序列不可用', err);
});
}
function maybeDrawBaseline() {
var times = candleTimesSec();
if (!times.length) return;
drawRatioBaseline(times.map(function (t) { return { time: t, value: 1 }; }));
syncSentimentTime();
}
function loadOverlays() {
if (!isCrypto()) {
clearAllSeries();
return;
}
if (!tvWidget || !tvWidget.mainChart) return;
var range = klineRangeMs();
var symbol = currentSymbol();
var req = ++overlayReq;
METRICS.forEach(function (spec) {
loadOne(spec, range, symbol, req);
});
maybeDrawBaseline();
}
function loadSnapshot() {
if (!isCrypto() || !window.ChanApi || !ChanApi.derivatives) return;
var req = ++snapshotReq;
ChanApi.derivatives({ symbol: currentSymbol() }).then(function (row) {
if (req !== snapshotReq) return;
var chg = Number(row.oi_change_pct);
var fund = Number(row.funding_rate);
var basis = Number(row.basis);
setChip('derivOi', 'OI ' + fmtOi(row.open_interest));
setChip('derivOiChg', 'Δ ' + fmtChg(row.oi_change_pct), tone(chg));
setChip('derivFunding', '费率 ' + fmtFunding(row.funding_rate), tone(fund));
setChip('derivBasis', '基差 ' + fmtBasis(row.basis), tone(basis));
var src = document.getElementById('derivSrc');
if (src) src.textContent = row.exchange || '';
}).catch(function (err) {
if (req !== snapshotReq) return;
console.warn('资金面快照不可用', err);
renderDerivEmpty();
var src = document.getElementById('derivSrc');
if (src) src.textContent = '不可用';
});
}
function loadLatest() {
if (!isCrypto() || !window.ChanApi || !ChanApi.sentimentLatest) return;
var req = ++latestReq;
ChanApi.sentimentLatest({ symbol: currentSymbol() }).then(function (payload) {
if (req !== latestReq) return;
var data = (payload && payload.data) || {};
var taker = data.taker_buy_sell_ratio || {};
var ls = data.long_short_account_ratio || {};
var top = data.top_long_short_position_ratio || {};
var takerN = Number(taker.buy_sell_ratio);
var lsN = Number(ls.long_short_ratio);
var topN = Number(top.long_short_ratio);
setChip('derivTaker', '买卖比 ' + fmtRatio(taker.buy_sell_ratio), isFinite(takerN) ? (takerN >= 1 ? 'up' : 'down') : null);
setChip('derivLs', '多空 ' + fmtRatio(ls.long_short_ratio), isFinite(lsN) ? (lsN >= 1 ? 'up' : 'down') : null);
setChip('derivTop', '大户 ' + fmtRatio(top.long_short_ratio), isFinite(topN) ? (topN >= 1 ? 'up' : 'down') : null);
}).catch(function (err) {
if (req !== latestReq) return;
console.warn('情绪快照不可用', err);
renderSentimentEmpty();
});
}
function sync(opts) {
opts = opts || {};
var show = isCrypto();
setVisible(show);
if (!show) return;
loadSnapshot();
loadLatest();
if (opts.overlay !== false) loadOverlays();
}
return {
sync: sync,
setVisible: setVisible,
loadOiOverlay: loadOverlays,
loadOverlays: loadOverlays
};
})();
+2 -2
View File
@@ -151,7 +151,7 @@ $('#elementTimeframe').change(function() {
// 检查选择的元素时间周期是否小于等于主周期
if (compareTimeframes(elementTimeframe, mainTimeframe) > 0) {
alert('元素时间周期必须小于或等于主图表时间周期。');
alert('必须小于或等于主。');
setSmallerOrEqualTimeframe(); // 重置为最大的小于等于时间周期
return;
}
@@ -164,7 +164,7 @@ $('#subSubTimeframe').change(function() {
const subSub = $(this).val();
const elementTf = $('#elementTimeframe').val();
if (compareTimeframes(subSub, elementTf) > 0) {
alert('次次周期必须小于或等于次周期。');
alert('次次必须小于或等于次。');
ensureSubSubLteElement();
return;
}
+62 -2
View File
@@ -43,6 +43,67 @@ function clearChanMacdMarkers() {
}
// 清空全局UnitTF标记,避免旧数据残留影响主图合并
window.unittfMarkers = [];
window.unittfMarkersMain = [];
window.unittfMarkersElement = [];
window.unittfMarkersSubSub = [];
}
function unittfDirSign(dir) {
if (dir === 'ABOVE' || dir === 1 || dir === '1' || dir === true) return 1;
if (dir === 'UNDER' || dir === -1 || dir === '-1') return -1;
const n = Number(dir);
if (n > 0) return 1;
if (n < 0) return -1;
return 0;
}
function buildUnittfOverlayMarkers(unittfList, opt) {
const markers = [];
if (!Array.isArray(unittfList) || !opt) return markers;
const up = opt.up, down = opt.down, prefix = opt.prefix || 'U';
const size = opt.size || 0.5;
for (let i = 0; i < unittfList.length; i++) {
const unittf = unittfList[i];
if (!unittf || !unittf.start_time || unittf.invalid) continue;
const startTime = Math.floor(new Date(unittf.start_time).getTime() / 1000);
if (isNaN(startTime)) continue;
const sign = unittfDirSign(unittf.dir);
const color = sign > 0 ? up : down;
const pos = sign > 0 ? 'aboveBar' : 'belowBar';
markers.push({ time: startTime, position: pos, color: color, shape: 'circle', text: prefix + i, size: size });
if (unittf.end_time) {
const endTime = Math.floor(new Date(unittf.end_time).getTime() / 1000);
if (!isNaN(endTime)) {
markers.push({ time: endTime, position: pos, color: color, shape: 'circle', text: prefix + i + 'E', size: size });
}
}
}
for (let i = 0; i + 1 < unittfList.length; i++) {
const cur = unittfList[i];
const nxt = unittfList[i + 1];
if (!cur || !nxt || !cur.end_time || !nxt.start_time) continue;
const tEnd = new Date(cur.end_time).getTime();
const tStart = new Date(nxt.start_time).getTime();
if (!isNaN(tEnd) && tEnd === tStart) {
const sign = unittfDirSign(nxt.dir);
markers.push({
time: Math.floor(tEnd / 1000),
position: sign > 0 ? 'aboveBar' : 'belowBar',
color: sign > 0 ? (opt.boundaryUp || up) : (opt.boundaryDown || down),
shape: 'square',
text: prefix + '↔',
size: 0.6
});
}
}
return markers;
}
function refreshUnittfOverlayFromData(data) {
// U/穿零轴只画在 MACD 副图,不再铺到主图
window.unittfMarkersMain = [];
window.unittfMarkersElement = [];
window.unittfMarkersSubSub = [];
}
// 添加所有ChanMACD标记
function addAllChanMacdMarkers(segList, unittfList, histsetList, stateMarkers) {
@@ -215,8 +276,7 @@ function addAllChanMacdMarkers(segList, unittfList, histsetList, stateMarkers) {
// 保存到全局,供主图与分型一起统一合并绘制(仅在开关开启时)
console.log('DEBUG: U 标记数量:', signalMarkers.length);
const allowUMerge = (window.showUOnMain && window.showUOnElement);
window.unittfMarkers = allowUMerge ? [...signalMarkers, ...boundaryMarkers] : [];
window.unittfMarkers = [...signalMarkers, ...boundaryMarkers];
if (uTooltipMarkers.length > 0) {
if (window.fxMarkers) {
window.fxMarkers = [ ...window.fxMarkers, ...uTooltipMarkers ];
+17 -15
View File
@@ -26,10 +26,10 @@ function loadSymbols() {
});
}
// 设置默认时间范围:最近 1 个月
// 设置默认时间范围:现在倒退 1
function setDefaultTimeRange() {
const now = new Date();
const daysBack = 30;
const daysBack = 7;
const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
@@ -72,17 +72,20 @@ $(document).ready(function() {
window.astockStatusInterval = null;
}
loadSymbols();
if (window.ChanDeriv) ChanDeriv.setVisible(true);
} else if (dataSource === 'a_stock') {
$('#cryptoSymbolContainer').hide();
$('#astockSymbolContainer').show();
loadAStockSymbols();
startAStockStatusUpdater();
if (window.ChanDeriv) ChanDeriv.setVisible(false);
}
});
});
// 检查初始数据源设置
const initialDataSource = $('#dataSource').val();
if (window.ChanDeriv) ChanDeriv.setVisible(initialDataSource !== 'a_stock');
if (initialDataSource === 'a_stock') {
$.getJSON('/api/chart_metadata', { source: 'a_stock' })
.done(function(meta) {
@@ -404,6 +407,7 @@ function mapTimeframeToInterval(timeframe) {
'5m': '5',
'15m': '15',
'30m': '30',
'45m': '45',
'1h': '60',
'2h': '120',
'4h': '240',
@@ -547,6 +551,7 @@ function refreshChart(data, options) {
if (currentData && currentData.ema52_dict) {
updateEMA52Display(currentData);
}
if (window.ChanDeriv) ChanDeriv.sync({ overlay: false });
return;
} catch (e) {
console.warn('增量刷新失败,回退全量重建:', e);
@@ -584,6 +589,7 @@ function refreshChart(data, options) {
if (currentData && currentData.ema52_dict) {
updateEMA52Display(currentData);
}
if (window.ChanDeriv) ChanDeriv.sync({ overlay: true });
}
@@ -597,14 +603,9 @@ function refreshChartOnly() {
}
}
// 绑定主周期MACD背离显示开关
$('#showMainMacdDiv').change(function() {
refreshChartOnly();
});
// 绑定次周期MACD背离显示开关
$('#showElementMacdDiv').change(function() {
refreshChartOnly();
// 绑定主/次/次次周期笔面积、线段面积显示开关
$('#showMainBiArea, #showMainSegArea, #showElementBiArea, #showElementSegArea, #showSubSubBiArea, #showSubSubSegArea').change(function() {
updateChartDisplay();
});
// 绑定分型类型显示开关(与笔一致:全量重建,避免增量路径标记未对齐)
@@ -642,6 +643,12 @@ $('#toggleUOnElement').change(function() {
});
// 买卖点显示开关
$('#showOi').change(function() {
if (window.ChanDeriv) ChanDeriv.loadOverlays();
});
$('#showDeriv').change(function() {
updateChartDisplay();
});
$('#showMainBsp').change(function() {
updateChartDisplay();
});
@@ -649,11 +656,6 @@ $('#showElementBsp').change(function() {
updateChartDisplay();
});
// 第四类买卖点(B4/S4)显示开关与过滤模式
$('#showMainFastBsp, #showElementFastBsp, #showSubSubFastBsp, #fastBspFilterMode').change(function() {
updateChartDisplay();
});
// 在控制台输出当前显示状态
console.log('当前显示状态:', {
'showOriginalKline': $('#showOriginalKline').is(':checked'),
+103 -44
View File
@@ -858,6 +858,38 @@
color: #999;
margin-top: 2px;
}
.deriv-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0 0 8px;
padding: 6px 10px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
font-size: 13px;
}
.deriv-bar .deriv-label {
font-weight: 600;
color: #495057;
margin-right: 4px;
}
.deriv-bar .deriv-chip {
font-variant-numeric: tabular-nums;
color: #343a40;
padding: 1px 6px;
border-radius: 4px;
background: #fff;
border: 1px solid #e9ecef;
}
.deriv-bar .deriv-chip.up { color: #198754; }
.deriv-bar .deriv-chip.down { color: #dc3545; }
.deriv-bar .deriv-src {
color: #868e96;
font-size: 12px;
margin-left: auto;
}
</style>
</head>
<body>
@@ -935,21 +967,31 @@
<!-- 添加K线周期切换 -->
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="klinePeriod" id="mainPeriodKline" checked>
<label class="form-check-label" for="mainPeriodKline">周期</label>
<label class="form-check-label" for="mainPeriodKline"></label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="klinePeriod" id="elementPeriodKline">
<label class="form-check-label" for="elementPeriodKline">小周期</label>
<label class="form-check-label" for="elementPeriodKline"></label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="klinePeriod" id="subSubPeriodKline">
<label class="form-check-label" for="subSubPeriodKline">次次周期</label>
<label class="form-check-label" for="subSubPeriodKline">次次</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMacd" checked>
<label class="form-check-label" for="showMacd">ChanMACD</label>
<label class="form-check-label" for="showMacd">MACD</label>
<button class="btn btn-sm btn-outline-secondary ms-1 p-0" onclick="showMacdConfig()" style="width:22px;height:22px;line-height:1;font-size:12px;" title="MACD参数设置"></button>
</div>
<span id="showSentimentWrap" class="d-inline-flex align-items-center">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showOi" checked>
<label class="form-check-label" for="showOi">OI</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showDeriv" checked>
<label class="form-check-label" for="showDeriv">衍生品</label>
</div>
</span>
<div class="d-flex align-items-center mb-2">
<label for="refreshInterval" class="form-label me-2 mb-0">自动刷新:</label>
<select id="refreshInterval" class="form-select form-select-sm me-2" style="width: 80px;">
@@ -977,7 +1019,7 @@
</div>
</div>
<div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">周期:</label>
<label class="form-label me-0 mb-0">主:</label>
<div class="form-check form-check-inline">
<select id="timeframe" class="form-select form-select-sm me-2" style="width: 100px;">
{% for value, label in timeframes.items() %}
@@ -998,11 +1040,11 @@
<label class="form-check-label" for="showMainZs">SEG中枢</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainBiZs">
<input class="form-check-input" type="checkbox" id="showMainBiZs" checked>
<label class="form-check-label" for="showMainBiZs">BI中枢</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showKlcFxType" checked>
<input class="form-check-input" type="checkbox" id="showKlcFxType">
<label class="form-check-label" for="showKlcFxType">KLC分型</label>
</div>
<div class="form-check form-check-inline">
@@ -1010,27 +1052,24 @@
<label class="form-check-label" for="showMainTrend">Trend</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="toggleUOnMain">
<input class="form-check-input" type="checkbox" id="toggleUOnMain" checked>
<label class="form-check-label" for="toggleUOnMain">显示U</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainBiArea">
<label class="form-check-label" for="showMainBiArea">笔面积</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainSegArea">
<label class="form-check-label" for="showMainSegArea">线段面积</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainBsp">
<label class="form-check-label" for="showMainBsp">买卖点</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainFastBsp">
<label class="form-check-label" for="showMainFastBsp">第四类</label>
</div>
<div class="form-check form-check-inline">
<select id="fastBspFilterMode" class="form-select form-select-sm" style="width: 130px;"
title="深色为区间套(大级别分型同向)+中枢顺向推进都满足的信号,浅色为未通过过滤">
<option value="all" selected>第四类:全部</option>
<option value="filtered">第四类:仅过滤后</option>
</select>
</div>
</div>
<div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">周期:</label>
<label class="form-label me-0 mb-0">次:</label>
<div class="form-check form-check-inline">
<select id="elementTimeframe" class="form-select form-select-sm me-2" style="width: 100px;">
{% for value, label in timeframes.items() %}
@@ -1066,17 +1105,21 @@
<input class="form-check-input" type="checkbox" id="toggleUOnElement">
<label class="form-check-label" for="toggleUOnElement">显示U</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementBiArea">
<label class="form-check-label" for="showElementBiArea">笔面积</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementSegArea">
<label class="form-check-label" for="showElementSegArea">线段面积</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementBsp">
<label class="form-check-label" for="showElementBsp">买卖点</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementFastBsp">
<label class="form-check-label" for="showElementFastBsp">第四类</label>
</div>
</div>
<div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">次次周期:</label>
<label class="form-label me-0 mb-0">次次:</label>
<div class="form-check form-check-inline">
<select id="subSubTimeframe" class="form-select form-select-sm me-2" style="width: 100px;">
{% for value, label in timeframes.items() %}
@@ -1113,12 +1156,16 @@
<label class="form-check-label" for="toggleUOnSubSub">显示U</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubBsp">
<label class="form-check-label" for="showSubSubBsp">买卖点</label>
<input class="form-check-input" type="checkbox" id="showSubSubBiArea">
<label class="form-check-label" for="showSubSubBiArea">笔面积</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubFastBsp">
<label class="form-check-label" for="showSubSubFastBsp">第四类</label>
<input class="form-check-input" type="checkbox" id="showSubSubSegArea">
<label class="form-check-label" for="showSubSubSegArea">线段面积</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubBsp">
<label class="form-check-label" for="showSubSubBsp">买卖点</label>
</div>
</div>
</div>
@@ -1126,6 +1173,17 @@
</div>
<div id="loadingIndicator" style="display:none;"></div>
<div id="derivBar" class="deriv-bar">
<span class="deriv-label">资金面</span>
<span class="deriv-chip" id="derivOi">OI —</span>
<span class="deriv-chip" id="derivOiChg">Δ —</span>
<span class="deriv-chip" id="derivFunding">费率 —</span>
<span class="deriv-chip" id="derivBasis">基差 —</span>
<span class="deriv-chip" id="derivTaker">买卖比 —</span>
<span class="deriv-chip" id="derivLs">多空 —</span>
<span class="deriv-chip" id="derivTop">大户 —</span>
<span class="deriv-src" id="derivSrc"></span>
</div>
<div class="chart-container">
<div id="tradingview_chart"></div>
@@ -1278,24 +1336,25 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}?v=20260910c"></script>
<script defer src="{{ url_for('static', filename='js/app/deriv_ui.js') }}?v=20260911i"></script>
<script defer src="{{ url_for('static', filename='js/app/state.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260810e"></script>
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260911j"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}?v=20260810e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260810e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_lifecycle.js') }}?v=20260810e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260810e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260810d"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260827a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260810a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260809z"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260827a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260912a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_lifecycle.js') }}?v=20260910c"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260911l"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260911n"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260910a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260911j"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260910c"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260910c"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260910c"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260911j"></script>
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260912a"></script>
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260901d"></script>
<!-- 均线配置弹窗 -->
<div id="maConfigModal" class="ma-config-modal">
@@ -1479,7 +1538,7 @@
<!-- MACD参数配置弹窗 -->
<div id="macdConfigModal" class="macd-config-modal">
<div class="macd-config-content">
<div class="macd-config-title">ChanMACD 参数设置</div>
<div class="macd-config-title">MACD 参数设置</div>
<div class="macd-config-form">
<div class="macd-config-group">
<label class="macd-config-label">快线周期 (Fast)</label>
+26
View File
@@ -105,6 +105,32 @@ def test_analyze_chan_keys_on_fixture():
assert k in result["chan_macd"]
def test_bi_and_seg_area_div_computed():
from services.runtime import add_indicators, analyze_chan
df = add_indicators(make_ohlcv(400))
result = analyze_chan(df, symbol="TEST/USDT:USDT", timeframe="5m")
bis = result["bi_list"]
segs = result["seg_list"]
assert bis, "fixture should produce bi"
assert all(hasattr(bi, "macd_div") for bi in bis)
assert all(hasattr(seg, "macd_div") for seg in segs)
assert all(hasattr(bi, "macd_hist") for bi in bis)
assert all(hasattr(seg, "macd_hist") for seg in segs)
same_dir_bis = [bi for bi in bis if getattr(bi, "pre", None) and getattr(bi.pre, "pre", None)]
if same_dir_bis:
bi = same_dir_bis[-1]
prev = bi.pre.pre
if prev.macd_hist:
assert abs(bi.macd_div - (bi.macd_hist / prev.macd_hist)) < 1e-9
same_dir_segs = [seg for seg in segs if getattr(seg, "pre", None) and getattr(seg.pre, "pre", None)]
if same_dir_segs:
seg = same_dir_segs[-1]
prev = seg.pre.pre
if prev.macd_hist:
assert abs(seg.macd_div - (seg.macd_hist / prev.macd_hist)) < 1e-9
def test_serialize_chan_macd_shape():
from pytz import timezone
+97
View File
@@ -0,0 +1,97 @@
"""资金面中转:Web 只打 data_provider,字段原样回给前端。"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
WEB_ROOT = Path(__file__).resolve().parents[1]
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(WEB_ROOT))
SNAPSHOT = {
"exchange": "bitget",
"symbol": "BTC/USDT:USDT",
"timestamp": 1789039236740,
"datetime": "2026-09-10T11:20:36.740000Z",
"funding_rate": 0.0001,
"open_interest": 36207.21,
"oi_change_pct": -0.01,
"basis": -0.03,
}
OI_HIST = {
"metric": "open_interest_history",
"symbol": "BTC/USDT:USDT",
"period": "15m",
"count": 1,
"data": [
{
"timestamp": 1789038900000,
"datetime": "2026-09-10T11:15:00Z",
"open_interest_amount": 106162.005,
"open_interest_value": 8269553076.678,
}
],
}
@pytest.fixture
def client():
from app import create_app
app = create_app()
app.config["TESTING"] = True
return app.test_client()
def test_derivatives_proxy_passthrough(client):
with patch("api.provider.fetch_derivatives", return_value=SNAPSHOT) as mock_fetch:
resp = client.get("/api/derivatives?symbol=BTC/USDT:USDT")
assert resp.status_code == 200
body = resp.get_json()
assert body["funding_rate"] == 0.0001
assert body["open_interest"] == 36207.21
assert body["oi_change_pct"] == -0.01
assert body["basis"] == -0.03
mock_fetch.assert_called_once()
assert mock_fetch.call_args[0][0] == "BTC/USDT:USDT"
def test_sentiment_metrics_proxy_passthrough(client):
with patch("api.provider.fetch_sentiment_metrics", return_value=OI_HIST) as mock_fetch:
resp = client.get(
"/api/sentiment/metrics?metric=open_interest_history&symbol=BTC/USDT:USDT&limit=1"
)
assert resp.status_code == 200
body = resp.get_json()
assert body["metric"] == "open_interest_history"
assert body["data"][0]["open_interest_amount"] == 106162.005
mock_fetch.assert_called_once()
assert mock_fetch.call_args[0][0] == "open_interest_history"
def test_sentiment_metrics_requires_metric(client):
resp = client.get("/api/sentiment/metrics?symbol=BTC/USDT:USDT")
assert resp.status_code == 400
def test_sentiment_latest_proxy_passthrough(client):
latest = {
"symbol": "BTC/USDT:USDT",
"data": {
"taker_buy_sell_ratio": {"buy_sell_ratio": 1.36},
"long_short_account_ratio": {"long_short_ratio": 1.5},
"top_long_short_position_ratio": {"long_short_ratio": 2.26},
},
}
with patch("api.provider.fetch_sentiment_latest", return_value=latest) as mock_fetch:
resp = client.get("/api/sentiment/latest?symbol=BTC/USDT:USDT")
assert resp.status_code == 200
body = resp.get_json()
assert body["data"]["taker_buy_sell_ratio"]["buy_sell_ratio"] == 1.36
mock_fetch.assert_called_once()