Files
Chan/research/live/patched_candles.py
jackandCursor 181bca303f 影子测量改用框架吃单原语,并补齐容量与 maker 成交率两项测算
吃单查询换成 Hummingbot 的 OrderBook.get_vwap_for_volume:手写的 walk_book
返回的是按计价币吃单的加权均价,但框架的 get_price_for_quote_volume 返回
边际价、get_vwap_for_volume 收基础币量,两者语义不同。改为按基础币下单
(真实委托与 PositionExecutor.amount 均是基础币计价),深度不足由
query_volume/result_volume 判定,框架此时返回 nan 而非一个看似正常的
部分成交均价。

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:39:47 +08:00

127 lines
5.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""修正 Hummingbot bitget_perpetual candles feed 的换根延迟。
上游 _parse_websocket_message 里是:
candle = data["data"][0]
而 Bitget 在换根时会推一条带两根的消息 [上一根, 新一根]。取 [0] 拿到的是
上一根,其时间戳与 deque 尾部相同,于是只做了原地更新;新一根要等下一条
单元素消息才进入 deque——实测晚约 1.1 秒。
不能简单改成 [-1]:那样上一根的收盘价就永远停在换根前约 1 秒的那次推送上。
1m 信号对 0.25bp 的扰动都会换掉一半(见 signal_sensitivity.py),收盘价
偏一个 tick 是不能接受的。所以这里把**除最后一根外的元素就地写回 deque**,
再把最后一根交给基类走正常的 append 流程。
已向上游反馈前,本地用子类覆盖,不改动镜像。
## 为什么必须有启动断言
子类覆盖的失效方式是**静默**的:上游若把 `_parse_websocket_message` 改名、
或改走别的钩子,我们的覆盖就成了死代码,行情悄悄退回慢 1.06 秒,不崩、
不报错、不留日志,只会让收益慢慢变差,几周后才从统计里看出来。
`assert_patch_effective()` 不做名字检查——名字对不上未必失效,名字对得上
也未必生效。它喂一条合成的两元素消息,直接验证行为:基类返回首元素(bug
仍在、覆盖仍有必要),子类返回末元素(覆盖确实生效)。再加一条源码检查
确认基类的收包循环还在调这个钩子。任一不满足就在启动时抛错。
"""
from __future__ import annotations
import inspect
from typing import Any, Dict, Optional
import numpy as np
from hummingbot.data_feed.candles_feed.bitget_perpetual_candles import (
BitgetPerpetualCandles,
)
from hummingbot.data_feed.candles_feed.candles_base import CandlesBase
def _row_to_dict(row: list, ensure_s) -> Dict[str, Any]:
return {"timestamp": ensure_s(int(row[0])),
"open": float(row[1]), "high": float(row[2]),
"low": float(row[3]), "close": float(row[4]),
"volume": float(row[5]), "quote_asset_volume": float(row[6]),
"n_trades": 0., "taker_buy_base_volume": 0.,
"taker_buy_quote_volume": 0.}
class PatchedBitgetPerpetualCandles(BitgetPerpetualCandles):
"""与上游唯一的差别:一条消息里的多根 K 线全部处理,而非只取第一根。"""
def _parse_websocket_message(self, data: dict) -> Optional[Dict[str, Any]]:
if data == "pong":
return None
if not (data and data.get("data") and data.get("action") == "update"):
return None
rows = data["data"]
# 前面的元素都是已收盘 K 线的最终值:就地覆盖,保住真实收盘价
for row in rows[:-1]:
d = _row_to_dict(row, self.ensure_timestamp_in_seconds)
self._overwrite_existing(d)
# 最后一根交给基类:时间戳更大就 append,相同就原地更新
return _row_to_dict(rows[-1], self.ensure_timestamp_in_seconds)
def _overwrite_existing(self, d: Dict[str, Any]) -> None:
if not len(self._candles):
return
ts = int(d["timestamp"])
if int(self._candles[-1][0]) != ts:
return
self._candles[-1] = np.array(
[d["timestamp"], d["open"], d["high"], d["low"], d["close"],
d["volume"], d["quote_asset_volume"], d["n_trades"],
d["taker_buy_base_volume"], d["taker_buy_quote_volume"]]
).astype(float)
# 换根时 Bitget 推的就是这个形状:[上一根, 新一根]
_PROBE = {
"action": "update",
"arg": {"instType": "USDT-FUTURES", "channel": "candle1m",
"instId": "BTCUSDT"},
"data": [
["1700000040000", "1", "1", "1", "1", "1", "1", "1"],
["1700000100000", "2", "2", "2", "2", "2", "2", "2"],
],
}
def assert_patch_effective() -> None:
"""启动即验证覆盖真的生效,否则抛错。让静默失效变成启动失败。"""
src = inspect.getsource(CandlesBase._process_websocket_messages_task)
if "_parse_websocket_message" not in src:
raise RuntimeError(
"上游收包循环已不再调用 _parse_websocket_message"
"patched_candles 的覆盖失效。需重新定位钩子后再启动。")
stock = BitgetPerpetualCandles("BTC-USDT", "1m", 20)
ours = PatchedBitgetPerpetualCandles("BTC-USDT", "1m", 20)
got_stock = stock._parse_websocket_message(_PROBE)
got_ours = ours._parse_websocket_message(_PROBE)
head_ts = stock.ensure_timestamp_in_seconds(int(_PROBE["data"][0][0]))
tail_ts = stock.ensure_timestamp_in_seconds(int(_PROBE["data"][-1][0]))
if not got_ours or int(got_ours["timestamp"]) != int(tail_ts):
raise RuntimeError(
f"覆盖未生效:子类返回 {got_ours and got_ours.get('timestamp')}"
f"应为末元素 {tail_ts}。")
if got_stock and int(got_stock["timestamp"]) == int(tail_ts):
# 上游自己修好了。此时覆盖无害但已多余,明确说出来,免得以后
# 有人以为那 1.06 秒还是靠我们拿回来的
print(" [补丁] 上游已自行修正换根解析,本地覆盖现为冗余,可移除",
flush=True)
elif not got_stock or int(got_stock["timestamp"]) != int(head_ts):
raise RuntimeError(
f"基类行为与预期不符:返回 "
f"{got_stock and got_stock.get('timestamp')}"
f"既非首元素 {head_ts} 也非末元素 {tail_ts}。"
f"上游改了解析逻辑,补丁的前提需重新确认。")
else:
print(f" [补丁] 覆盖生效:基类取首元素 {int(head_ts)}、"
f"本地取末元素 {int(tail_ts)}", flush=True)