2 Commits
Author SHA1 Message Date
jackyu66gitandCursor d2fcb27d01 否掉 bis[2]:修右边缘重画会把 alpha 打成零,重画是必付代价
§5.41 发现 available_ts 取「中枢最后一笔」是右边缘重画的根因,改取「第三笔」
能把重画率从 6.5% 压到 1.2%,当时据此判断它是「唯一可能同时改善收益与稳定性」
的改动。那个判断只测了稳定性,过早了。

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:04:44 +08:00
12 changed files with 2457 additions and 93 deletions
+30 -7
View File
@@ -44,6 +44,27 @@ import pandas as pd
from chanlun.core.ChanEnum import Chan_FX_TYPE
# 中枢的「可用时刻」取第几笔的确认时间。
# -1 = 最后一笔(历史默认)。中枢每吸收一根K线,最后一笔就可能后移,
# 于是 available_ts 跟着漂——这是右边缘重画的根因(见 HANDOFF §5.41)。
# 2 = 第三笔。三笔重叠即中枢成立,此后不再变,理论上更早也更稳。
# ⛔ step47 已判定 bis[2] 作废(毛 R 0.933 → 0.000,见 HANDOFF §5.42)。
# 开关保留只为可复现那次 A/B,**不要改默认值**。
import os as _os
AVAIL_BI_INDEX = -1
def _resolve_avail_bi(avail_bi: int | None) -> int:
"""优先级:显式入参 > 环境变量 CHAN_AVAIL_BI > 模块默认。
环境变量在调用时读取而非 import 时——ProcessPoolExecutor 在 fork 启动方式下
子进程会继承已 import 的模块,import 时读就固化成父进程的值了。
"""
if avail_bi is not None:
return avail_bi
return int(_os.environ.get("CHAN_AVAIL_BI", AVAIL_BI_INDEX))
def timestamps_ms(src: pd.DataFrame) -> np.ndarray:
"""取毫秒时间戳。研究侧的 df 自带 timestampweb 侧的不一定,故按 date 回退。
@@ -69,7 +90,7 @@ def ensure_timestamp(df: pd.DataFrame) -> pd.DataFrame:
return out
def build_htf_zones(df_htf: pd.DataFrame, tf: str, chan=None) -> pd.DataFrame:
def build_htf_zones(df_htf: pd.DataFrame, tf: str, chan=None, avail_bi: int | None = None) -> pd.DataFrame:
"""算 pure 笔中枢,返回带生效时间的区间表。
available_ts —— 该中枢最早可被使用的时间戳(其确认时刻)。
@@ -82,26 +103,28 @@ def build_htf_zones(df_htf: pd.DataFrame, tf: str, chan=None) -> pd.DataFrame:
zs_list = chan.cal_bi_zs_list_pure(chan.bi_list)
# 用引擎自己的 dataframe 对齐,避免调用方传入的 df 与引擎内部行数不一致
src = chan.dataframe if getattr(chan, "dataframe", None) is not None else df_htf
return zones_from_zs_list(zs_list, src)
return zones_from_zs_list(zs_list, src, avail_bi=avail_bi)
def zones_from_zs_list(zs_list, src: pd.DataFrame) -> pd.DataFrame:
def zones_from_zs_list(zs_list, src: pd.DataFrame, avail_bi: int | None = None) -> pd.DataFrame:
"""把已算好的 pure 笔中枢转成区间表。
调用方手工跑过 cal_bi_zs_list_pure 时走这里,免得再算一遍(web 的 analyze_chan
就是这种用法)。
"""
ts_of = dict(zip(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"), timestamps_ms(src)))
# 中枢可用时刻取第几笔。在循环外解析一次,别让每个中枢都去读一遍环境变量。
i = _resolve_avail_bi(avail_bi)
rows = []
for zs in zs_list:
bis = getattr(zs, "bi_list", [])
if not bis:
continue
# 中枢可用时刻:构成它的最后一笔被确认之时
last_bi = bis[-1]
sure_key = str(getattr(last_bi, "sure_time", "") or "")
end_key = str(getattr(last_bi, "end_time", "") or "")
# 笔数不够时退回最后一笔(i=2 需要至少 3 笔才成立)
key_bi = bis[i] if (i == -1 or len(bis) > i) else bis[-1]
sure_key = str(getattr(key_bi, "sure_time", "") or "")
end_key = str(getattr(key_bi, "end_time", "") or "")
avail = ts_of.get(sure_key) or ts_of.get(end_key)
if avail is None:
continue
+32 -8
View File
@@ -63,10 +63,11 @@ class ChanKLC():
self.bsp = False
self.bsp_type = Chan_BSP_TYPE.NONE
# EMA状态字典:key为EMA名称,value为 {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC}
self.ema_status = {}
self._ema_status = {}
self._ema_status_dirty = False
# 向后兼容:保留 ema52_status 和 ema52_pos
self.ema52_status = 0
self.ema52_pos = Chan_EMA_POS.UNKNOWN
self._ema52_status = 0
self._ema52_pos = Chan_EMA_POS.UNKNOWN
self.bb2633upper = klu.bb2633upper
self.bb2633lower = klu.bb2633lower
self.bb2633middle = klu.bb2633middle
@@ -283,21 +284,44 @@ class ChanKLC():
'ema156': self.ema156,
'ema208': self.ema208,
}
self.ema_status = {}
self._ema_status_dirty = False
self._ema_status = {}
for name, value in ema_configs.items():
# 按 EMA 值的百分比自动计算阈值
threshold = abs(value) * self.threshold_pct if value and self.threshold_pct > 0 else 0
pos = ChanKLC.cal_ema_pos(self.high, self.low, self.close, value, threshold)
semantic = ChanKLC.cal_ema_semantic(pos, self.dir, self.ema_dir)
self.ema_status[name] = {
self._ema_status[name] = {
'pos': pos,
'semantic': semantic,
'value': value,
'threshold': threshold,
}
# 向后兼容
self.ema52_pos = self.ema_status['ema52']['pos']
self.ema52_status = ChanKLC.semantic_to_int(self.ema_status['ema52']['semantic'])
self._ema52_pos = self._ema_status['ema52']['pos']
self._ema52_status = ChanKLC.semantic_to_int(self._ema_status['ema52']['semantic'])
# 以下三个改成惰性求值。原来 set_end_klu 每次合并 KLU 都会立刻重算一遍,
# 实测占 TF_DF 构建的约 25%,而全仓(含前端)没有任何地方读取它的产出。
# 保留属性形式是为了任何外部读取仍拿到正确值,只是推迟到真被读时才算。
@property
def ema_status(self):
if self._ema_status_dirty:
self.cal_all_ema_status()
return self._ema_status
@property
def ema52_pos(self):
if self._ema_status_dirty:
self.cal_all_ema_status()
return self._ema52_pos
@property
def ema52_status(self):
if self._ema_status_dirty:
self.cal_all_ema_status()
return self._ema52_status
def get_ema_pos(self, ema_name):
"""获取指定EMA的客观位置,如 klc.get_ema_pos('ema24')"""
if ema_name in self.ema_status:
@@ -448,7 +472,7 @@ class ChanKLC():
klu.set_klc(self)
self.klc_dir = Chan_KLINE_DIR.UP if self.close > self.open else Chan_KLINE_DIR.DOWN
self.cal_indicators()
self.cal_all_ema_status()
self._ema_status_dirty = True
if self.open > self.high:
self.open = self.high
if self.close > self.high:
+33 -20
View File
@@ -160,27 +160,40 @@ class ChanKLU:
return False
else:
return True
# 属性名 ← dataframe 列名。两条赋值路径(逐行 Series / 预取 ndarray)共用这张表,
# 免得将来加指标时只改一处、另一处静默漏掉。
INDICATOR_FIELDS = (
('macd', 'macd'), ('signal', 'macdsignal'), ('macdhist', 'macdhist'),
('ema26', 'ema26'), ('ema52', 'ema52'), ('ema24', 'ema24'),
('ema104', 'ema104'), ('ema156', 'ema156'), ('ema208', 'ema208'),
('ema13', 'ema13'), ('ema7', 'ema7'), ('ema5', 'ema5'),
('rsi', 'rsi'), ('volume_ratio', 'volume_ratio'),
('bb52upper', 'bb52upper'), ('bb52lower', 'bb52lower'),
('bb2633upper', 'bb2633upper'), ('bb2633lower', 'bb2633lower'),
('bb2633middle', 'bb2633middle'), ('ma5', 'ma5'),
)
def set_indicators(self, item):
self.macd = float(item['macd']) if 'macd' in item and item['macd'] else 0
self.signal = float(item['macdsignal']) if 'macdsignal' in item and item['macdsignal'] else 0
self.macdhist = float(item['macdhist']) if 'macdhist' in item and item['macdhist'] else 0
self.ema26 = float(item['ema26']) if 'ema26' in item and item['ema26'] else 0
self.ema52 = float(item['ema52']) if 'ema52' in item and item['ema52'] else 0
self.ema24 = float(item['ema24']) if 'ema24' in item and item['ema24'] else 0
self.ema104 = float(item['ema104']) if 'ema104' in item and item['ema104'] else 0
self.ema156 = float(item['ema156']) if 'ema156' in item and item['ema156'] else 0
self.ema208 = float(item['ema208']) if 'ema208' in item and item['ema208'] else 0
self.ema13 = float(item['ema13']) if 'ema13' in item and item['ema13'] else 0
self.ema7 = float(item['ema7']) if 'ema7' in item and item['ema7'] else 0
self.rsi = float(item['rsi']) if 'rsi' in item and item['rsi'] else 0
self.volume_ratio = float(item['volume_ratio']) if 'volume_ratio' in item and item['volume_ratio'] else 0
self.bb52upper = float(item['bb52upper']) if 'bb52upper' in item and item['bb52upper'] else 0
self.bb52lower = float(item['bb52lower']) if 'bb52lower' in item and item['bb52lower'] else 0
self.bb2633upper = float(item['bb2633upper']) if 'bb2633upper' in item and item['bb2633upper'] else 0
self.bb2633lower = float(item['bb2633lower']) if 'bb2633lower' in item and item['bb2633lower'] else 0
self.bb2633middle = float(item['bb2633middle']) if 'bb2633middle' in item and item['bb2633middle'] else 0
self.ma5 = float(item['ma5']) if 'ma5' in item and item['ma5'] else 0
self.ema5 = float(item['ema5']) if 'ema5' in item and item['ema5'] else 0
"""单根赋值。增量追加时每次只有一根,走这条即可。
原写法是 `float(item[c]) if c in item and item[c] else 0`。其中的真值判断
是空转:值为 0.0 时 float(0.0) 仍是 0,值为 NaN 时 NaN 为真值、照样透传。
唯一起作用的是「列不存在则填 0」,所以这里只保留那一层。
"""
for attr, col in self.INDICATOR_FIELDS:
v = item[col] if col in item else 0
setattr(self, attr, float(v) if v else 0)
def set_indicators_from(self, cols, i):
"""从预取的 {列名: ndarray} 按下标赋值,语义与 set_indicators 相同。
全量构建时用这条:避免每根 `df.iloc[i]` 构造一个 Series,再在其上做
几十次逐键查找——那是 TF_DF 构建 96% 的耗时所在。
"""
for attr, col in self.INDICATOR_FIELDS:
arr = cols.get(col)
v = arr[i] if arr is not None else 0
setattr(self, attr, float(v) if v else 0)
def cal_macd_state(self):
# 按定义精简实现:优先级 CROSS0 > 位置(HIGH/HE/RETURN_ZERO) > NEAR0 > UNKNOWN
# 首条或缺前一根
+46 -49
View File
@@ -147,41 +147,53 @@ class KlineBuilderMixin:
return df['volume_ratio']
def cal_kl_data(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
"""按行构造 KLU 链。
这里刻意不用 `dataframe.iloc[i]`:那会为每一根新建一个几十列的 Series,
随后 set_indicators 再在其上做几十次逐键查找。实测这两件事合计占 TF_DF
构建耗时的 96%。改为先把用到的列取成 ndarray,循环里只做整数下标访问。
"""
n = len(dataframe)
if n == 0:
return []
times = self._format_times(dataframe['date'])
o_a = dataframe['open'].to_numpy(dtype=float)
h_a = dataframe['high'].to_numpy(dtype=float)
l_a = dataframe['low'].to_numpy(dtype=float)
c_a = dataframe['close'].to_numpy(dtype=float)
v_a = dataframe['volume'].to_numpy(dtype=float)
has_ind = 'macd' in dataframe.columns
ind_cols = {}
if has_ind:
for _attr, col in ChanKLU.INDICATOR_FIELDS:
if col in dataframe.columns:
ind_cols[col] = dataframe[col].to_numpy(dtype=float)
klu_list = []
last_klu = None
for i in range(0, len(dataframe)):
item = dataframe.iloc[i]
date = item['date']
o = item['open']
h = item['high']
l = item['low']
c = item['close']
v = item['volume']
# time_obj = date.fromtimestamp(date)
# date = date + timedelta(hours=8)
time_str = date.strftime('%Y-%m-%d %H:%M:%S')
item_data = [
time_str,
o,
h,
l,
c,
v
]
# klu = KLU(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)))
klu = ChanKLU(time_str, o, h, l, c, v)
# print(klu.time, klu.open, klu.high, klu.low, klu.close, klu.volume)
for i in range(n):
klu = ChanKLU(times[i], o_a[i], h_a[i], l_a[i], c_a[i], v_a[i])
klu.set_idx(i)
klu_list.append(klu)
if last_klu:
last_klu.set_next(klu)
klu.set_pre(last_klu)
last_klu = klu
if 'macd' in item:
klu.set_indicators(item)
if has_ind:
klu.set_indicators_from(ind_cols, i)
return klu_list
@staticmethod
def _format_times(col):
"""向量化 strftime。非 datetime 列(少见)退回逐个格式化。"""
fmt = '%Y-%m-%d %H:%M:%S'
try:
return col.dt.strftime(fmt).to_numpy()
except AttributeError:
return np.array([d.strftime(fmt) for d in col], dtype=object)
def get_kl_data(self, dataframe:DataFrame):
return self.cal_kl_data(dataframe)
@@ -224,35 +236,20 @@ class KlineBuilderMixin:
def get_klc_list(self, klu_list):
klc_list = []
last_klu = None
# ChanMACD.__init__ 已调用 cal_macd_state,切勿再调一次(会重复堆积 seg/unittf)
macd = ChanMACD(klu_list)
klu_list = macd.klu_list
self._last_chan_macd = macd
ema_up_list = []
ema_down_list = []
ema_up_count = 0
ema_down_count = 0
# ChanMACD.__init__ 已调用 cal_macd_state,切勿再调一次(会重复堆积 seg/unittf)。
# lean 模式跳过整套 MACD 状态机:它只服务于 bsp/背驰/web 展示,笔与中枢不依赖它。
if getattr(self, 'lean', False):
self._last_chan_macd = None
else:
macd = ChanMACD(klu_list)
klu_list = macd.klu_list
self._last_chan_macd = macd
last_klu = None
for klu in klu_list:
ema = klu.ema52
last_ema = last_klu.ema52 if last_klu else 0
if klu.close >= ema:
ema_up_count += 1
elif klu.close < ema:
ema_down_count += 1
if last_klu and last_klu.close >= last_ema and klu.close < ema:
ema_up_list.append(ema_up_count)
#print(last_klu.time, ema_up_count, "UP END")
ema_up_count = 0
elif last_klu and last_klu.close < last_ema and klu.close >= ema:
ema_down_list.append(ema_down_count)
#print(last_klu.time, ema_down_count, "DOWN END")
ema_down_count = 0
self._push_klu_into_klc_list(klc_list, klu, last_klu)
last_klu = klu
klc_list = self.cal_trend(klc_list)
#print(ema52_up_list, ema52_down_list)
return klc_list
+15 -3
View File
@@ -38,10 +38,18 @@ from chanlun.pipeline.builders.seg import SegBuilderMixin
from chanlun.pipeline.builders.zs import ZsBuilderMixin
class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin, FastBspBuilderMixin, IncrementalBuilderMixin):
def __init__(self, df=None, interval=0, timeframe=None):
def __init__(self, df=None, interval=0, timeframe=None, lean=False):
"""lean=True 只构建到中枢,跳过线段/走势中枢/MACD 状态机。
研究与实盘只吃 bi_list → 中枢 → fast_bsp 这条链;线段、zs、big_zs 和整套
MACD 背驰状态机是 web 展示与 bsp_list 才用的。实测这些占全量构建的约四成。
注意 lean 下 bsp_list/seg_list/chanmacd 均为空,**不要给 web 用**。
"""
self.lean = lean
if df is not None:
self.init_TF_DF(df, interval, timeframe)
def init_TF_DF(self, df, interval, timeframe):
self.init_TF_DF(df, interval, timeframe, lean=lean)
def init_TF_DF(self, df, interval, timeframe, lean=False):
self.lean = lean
self.timeframe = timeframe
self.interval = interval
# 检查 DataFrame 是否为空或没有 date 列
@@ -69,6 +77,10 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde
self.klc_list = self.get_klc_list(self.klu_list)
self.bi_list = self.cal_bi_list(self.klc_list)
self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list)
if self.lean:
self.big_zs_list = []
self.chanmacd = None
return
self.seg_list = self.get_seg_list(self.bi_list)
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
self.big_zs_list = self.get_big_zs_list(self.zs_list)
+482 -6
View File
@@ -267,13 +267,39 @@ push = z_above if direction == 1 else z_below
#### 3.33 扩币的筛选标准(两条方向相反的约束)
1. **ATR 分布对门控阈值**(当前 8bp)——小市值币波动大,这条更容易过
2. **流动性 / 盘口价差**——决定滑点能否压进预算,小市值币更难过
1. **ATR 分布对门控阈值**(当前 8bp)——够不够扛住固定成本
2. **流动性 / 盘口价差**——滑点能否压进预算
**两条方向相反,所以最优区间在中间,不在两端。** 现有 11 币正好演示:
BTC ATR 中位 2026 仅 6.5bp、门控刷掉 58.5%、预算 8.58bp 垫底(**大市值输在
波动不够**);TRX 2026 门控后只剩 4.2% 的信号,当前环境基本不能做。
最好的是中间那批:ADA 22.28、DOGE 18.55、XRP 17.04。
**两条都是及格线,不是「越高越好」的排序依据。**
⚠️ **「那就做 ATR 最大的」是错的**,两个方向都不支持:
**币内**(每个币按自己的 ATR 分位)——严格单调,高 ATR 时刻更差:
| 币内 ATR 分位 | 净均R | R夏普 |
|---|---|---|
| 最低 20% | **1.040** | **0.690** |
| 40-60% | 0.982 | 0.655 |
| 最高 20% | **0.878** | 0.599 |
ATR 飙升往往意味着行情已走过头,噪声大、容易反抽。
(但不必加上限门控:最高分位的 0.878 仍然是好交易,砍掉只是白丢机会。)
**跨币**——看似正相关(ATR vs 净均R r=+0.684),但**剔除 BTC 后掉到 +0.469**
且其余 9 币的净均R 区间只有 0.935~1.024(极差 0.089)。BTC 自己 0.787
明显低于所有其他币。BTC 差更可能因为它是最大最有效的市场、结构性机会少,
把它的低 ATR 与低 R 读成因果是过度拟合一个点。
**ATR 唯一真正提供的是 bp 余量**r=+0.85,剔 BTC 后仍 +0.85)。
但这是**机械关系**——余量按定义随 ATR 缩放。它有用(滑点大致固定在 bp,
余量高就能扛更多滑点),但不代表 ATR 高的币「更赚」。
⚠️ **追高 ATR 还会撞上这份数据看不见的流动性墙。** 现有 11 币全是头部流动性;
高 ATR 通常意味着小市值、盘口更薄,多出来的余量很可能被滑点吃回去。
把在头部币上成立的关系外推到另一个流动性区间,是最容易翻车的那类外推。
现有 11 币的实际情况:BTC ATR 中位 2026 仅 6.5bp、门控刷掉 58.5%、
预算 8.58bp 垫底;TRX 2026 门控后只剩 4.2% 的信号,当前环境基本不能做。
筛选很便宜:拉候选币 1m 数据,只算 ATR 分位数与盘口价差,**不必跑策略**
就能排掉大半。
@@ -282,6 +308,152 @@ BTC ATR 中位 2026 仅 6.5bp、门控刷掉 58.5%、预算 8.58bp 垫底(**
全部为正」。若新币是在同一份历史上筛出来的,它们就不再是样本外。
要保持这个性质,得留一批筛完不看、直接进实盘验证。
#### 3.34 组合最优解:选币的杠杆很小,币数的杠杆在前 6 个
穷举每个 N 的最优子集(1m,门控后,固定日历,只扣手续费不扣滑点):
| 币数 | 最优子集夏普 | 增量 | 年化R | 若信号独立应有 | 捕获比例 |
|---|---|---|---|---|---|
| 1 | 9.94 | — | 323 | 9.94 | 100% |
| 2 | 13.15 | +3.21 | 611 | 14.06 | 94% |
| 4 | 16.93 | +1.61 | 1174 | 19.88 | 85% |
| 6 | 18.87 | +0.78 | 1672 | 24.35 | 77% |
| **8** | **19.87** | +0.38 | 2188 | 28.11 | 71% |
| 11 | 20.22 | +0.02 | 2690 | 32.97 | **61%** |
**三条结论:**
1. **边际收益在 6~8 币后归零。** 8→11 只换来 +0.35 夏普(+1.8%),
但年笔数多 500+,成本与运维复杂度是实打实的。
2. ~~**具体选哪几个几乎不重要。**~~ 165 个 8 币组合:最好 19.87ADA/AVAX/
DOGE/ETH/LINK/LTC/SOL/XRP)、最差 17.61、中位 18.66,极差 2.2612%)。
⚠️ **这一条已被 §3.37 推翻**——它忽略了盘口冲击。计入后币间差距是 5 倍,
且排序近乎翻转(AVAX 从必选变成必剔,BTC 反而可用)。**选币很重要。**
3. **相关性吃掉近 40% 的分散化收益**——捕获比例从 100% 单调降到 61%。
与 3.31 的「92.7% 同向」一致:加币是加机会,不是加分散。
⚠️ **并发保证金不是瓶颈,此前的担心是错的。**(早前按事件平均算出 0.92
高估了——事件在繁忙时段聚集。时间加权才是对的口径。)
| | 时间加权均并发 | 有仓时间占比 | 有仓时均并发 | p99 | 最大 |
|---|---|---|---|---|---|
| 全样本 11 币 | 0.15 | 12.3% | 1.24 | 2 | 9 |
| 2026 11 币 | 0.11 | 9.1% | 1.26 | 2 | 8 |
**资金 88% 的时间闲置**,扩币在保证金维度有大把余地。峰值 9 仍要按 3.31
的同向假设做限额,但那是尾部风控,不是容量约束。
⚠️ **上表夏普只扣手续费、未扣滑点,绝对值虚高(年化 20 不可信),
只能用于相对比较。** 真正的约束是实测滑点 vs 预算——见 5.3 与 3.35。
按杠杆排序:**滑点是生死(100%)> 币数 1→6(+90%)> 选哪几个(±12%,基本是噪声)**。
#### 3.36 成本模型的已知缺口:滑点被当成了常数,但它是仓位的函数 ⚠️
**全文所有预算数字都假设滑点是固定 bp,与下单规模无关。这是错的**
(用户 2026-08-28 指出)。且这个错误不是随机的——它系统性偏袒低 ATR 端。
固定分数下单意味着 **名义额 = 风险额 / (2·ATR),即仓位 ∝ 1/ATR**
而预算(bp)∝ ATR。**两者方向相反:**
| ATR 桶 bp | 中位杠杆 | 中位名义额(10万本金、每笔冒1%) | 预算 bp |
|---|---|---|---|
| 8-12 | 5.0x | **$502,029** | **13.7** |
| 12-16 | 3.6x | $362,362 | 19.5 |
| 16-22 | 2.7x | $269,999 | 23.1 |
| 22-30 | 2.0x | $198,172 | 29.3 |
| >30 | 1.3x | **$129,065** | **52.1** |
**低 ATR = 单子最大 + 预算最小 + 滑点随单子变大 = 三重挨打。**
所以按固定 bp 算出的逐币/分桶预算,**系统性低估了低 ATR 端的劣势**。
**ATR 门控因此比原论证更有价值**:被刷掉的信号中位杠杆 **8.9x、p90 17.6x**
(保留的是中位 3.2x、p90 5.3x)——门控砍掉的正是最大的单子。
这比「固定成本占比高」是更强的理由。
**三条待办(都属于「数据回来前不做、回来后会后悔」):**
1. **影子交易要记录信号时刻的盘口快照(≥10 档),不只是成交价。**
有深度就能离线算任意仓位的冲击成本——**一次测量回答所有资金量级**,
否则换个规模就得重测。若用极小量或 paper 测,得到的只是
`价差/2 + 漂移` 这个下界,必然乐观。
2. **资金容量要算出来,不要等实盘暴露。** 预算 20bp 蕴含一个资金上限。
中位名义额 $32 万、p90 $53 万,在 ADA/LINK/AVAX 的 1m 永续盘口上不是零头。
3. **maker 腿的仓位问题是成交率,不是冲击。** 回测假设 3 ATR 与 8 ATR
的限价单全额成交。$30 万挂单可能只部分成交,**而部分成交会改变分批出场
的收益结构**(§3.5 的结论建立在全额成交上)。需在影子数据里验证。
#### 3.37 计入盘口冲击后,币种排序几乎翻转 —— §3.34「选哪几个不重要」作废 ⚠️
Bitget USDT 永续实时盘口(2026-08-28 01:30 HKT5 次快照取中位)。
**净预算 = 回测预算 − $300k 单边吃单冲击**($300k ≈ 10万本金 × 中位杠杆 3.2x):
| 币 | 价差bp | ask深度 | $300k冲击 | 回测预算 | **净预算** | 冲击占预算 |
|---|---|---|---|---|---|---|
| SOL | 0.09 | $3.3M | 2.82 | 28.3 | **25.5** | 10% |
| DOGE | 1.12 | $13.5M | 4.35 | 28.5 | **24.1** | 15% |
| ETH | 0.04 | $19.4M | 0.27 | 20.8 | **20.5** | 1% |
| XRP | 0.68 | $14.9M | 4.82 | 24.5 | **19.7** | 20% |
| BTC | 0.01 | $4.5M | 0.26 | 14.6 | **14.3** | 2% |
| LINK | 0.84 | $2.8M | 12.20 | 26.2 | 14.0 | 47% |
| ADA | 4.62 | $6.3M | 9.70 | 23.5 | 13.8 | 41% |
| LTC | 1.99 | $3.2M | 11.13 | 23.6 | 12.5 | 47% |
| AVAX | 1.33 | $3.0M | 23.65 | 29.0 | **5.3** | **82%** |
**§3.34 的「165 个 8 币组合极差仅 12%、选哪几个不重要」是错的**——那个结论
完全建立在忽略流动性上。计入冲击后币间差距是 **5 倍**25.5 vs 5.3)。
**排序近乎翻转,根因是 ATR 与流动性负相关**,所以按预算(∝ATR)排出的序
恰好是按流动性排的逆序:
- **AVAX 原始预算最高(29.0)、净预算最低(5.3)**。它在最优 8 币里入选频率
95%,实际最该剔除。5 次快照 22.3~24.7bp,不是抽样噪声
- LINK / ADA / LTC(入选频率 95%/95%/70%)净预算全掉到 12~14
- **BTC 此前判为「中性」,实际净预算 14.3,反而优于 LINK/ADA/LTC**——
原始预算最低但冲击几乎为零
这就是 §3.33 定性警告过的「流动性墙」,现已量化,且比预想严重。
**真正的结论:可用币种是资金量的函数,不存在固定的最优币表。**
按 $100k 名义额(≈$32k 本金)重算,净预算回到 ADA 18.0 / LINK 17.2 /
LTC 16.5 / AVAX 11.6——除 AVAX 外全部健康。**扩币前先定本金。**
**当前运行点:本金 < $2k(用户 2026-08-28 确认,目的是先跑通流程)。**
在这个规模上 **§3.37 的重排序不适用**——单笔仅 $6.4k,冲击占预算 1~8%
(连 AVAX 也只有 8%),**9 个币全部可用,§3.34 的「选哪几个不重要」重新成立**。
分水岭在 **$20k~$50k 本金**:以上才需要收缩到 SOL/DOGE/ETH/XRP/BTC
(这 5 个在 $2k~$200k 全区间冲击 ≤21%,是与资金量无关的安全核心)。
冲击占预算比例(按本金,杠杆 3.2x):
| 币 | $2k | $10k | $20k | $50k | $100k |
|---|---|---|---|---|---|
| BTC / ETH | 0% | 0% | 0% | 0% | 0~2% |
| SOL | 1% | 3% | 4% | 5% | 6% |
| XRP / DOGE | 5~6% | 9~10% | 12% | 15% | 16~17% |
| LINK | 4% | 11% | 16% | **31%** | **46%** |
| LTC | 12% | 22% | **30%** | **41%** | **57%** |
| ADA | 20% | **28%** | **29%** | **38%** | **51%** |
| AVAX | 8% | **28%** | **43%** | **70%** | **91%** |
**小资金无操作性障碍**Bitget 最小下单量在 $300 本金都不卡,
半仓取整误差最差 2.4%ETH @ $500),其余 <1%。
⚠️ **但 $2k 跑通 ≠ 验证了策略容量。** 小资金下有两处系统性偏乐观、且不可外推:
(a) 冲击≈0,测出的滑点只剩 `价差/2 + 延迟漂移`,几乎必然通过 15.19bp
但不能推出 $50k 也通过;(b) **maker 腿成交率也是假的好**——$3.2k 挂单几乎
必然全成,$150k 不一定,而 §3.5 分批出场的收益结构全建立在全额成交上。
**→ 这正是「记录盘口快照」的价值:它是唯一能让小资金数据回答大资金问题的东西。**
只记成交价的话,每上一个资金台阶都得重测一遍。
$2k 阶段真正该盯的只有两件:**延迟与漂移**(与规模无关,结论长期有效)、
**中枢阶梯 + ATR 门控必须在线上生效**(否则测的是另一批信号,与回测不可比)。
⚠️ **三条保留**:(1) 单一时段快照,盘口有日内周期,需跨时段采样;
(2) **信号恰在波动放大时触发,那时盘口更薄——上表是乐观下界**
(3) 只算了入场 taker 腿,止损/超时同为 taker,maker 腿则是成交率问题。
第 (2) 条意味着这批静态数字**不能替代影子数据**,只是把「该测什么、该剔谁」
提前定下来了。
### 3.35 跨 venue:信号身份不迁移,但总体期望迁移(live 前置测量)
**回测全部跑在 Binance 数据上,实盘要在 Bitget 成交。** 服务器侧的
@@ -314,6 +486,39 @@ BTC 5.87 vs 6.09、ETH 10.02 vs 12.06、SOL 8.92 vs 9.87bp。
⚠️ 上面那批数字用的是 `毛均收益 6.0bp`、只有同向过滤、`TP=3.0`
**不能直接当预算用**,正确的逐币预算见 `lib/shadow_budget.py` 与 5.3。
### 3.38 浅色信号(未过双过滤)在 1m 上不能做——差在毛质量,不在成本(step44)
用户观察「图上很多浅色 b4/s4 的入场价看着也很有优势」。step40 做过同类消融但
只覆盖 15m/30m 且用旧口径(5bp 平摊、1.5/3.0/48),1m 从未验证。
`research/step44_filter_ablation_1m.py`,8 币 × 30 万根,当前最优 1m 出场
SL2.0 / 3ATR 减半 / runner 8ATR / rstop 留原位 / 48 根),真实费率,
ATR ≥ 8bp 门控后共 2302 笔:
| 口径 | 笔数 | 胜率 | 毛R | 净均R | 剔10%PF | 滑点余量bp |
|---|---|---|---|---|---|---|
| **深色**(同向 ∧ 阶梯) | 955 | 68.3% | **0.933** | 0.798 | 2.35 | **15.07** |
| **浅色**(任一不过) | 1347 | 44.3% | **0.155** | 0.009 | 0.74 | **0.10** |
| — 只过阶梯 | 445 | 52.1% | 0.419 | 0.279 | 1.03 | 4.18 |
| — 只过同向 | 265 | 38.1% | **0.029** | 0.176 | 0.59 | **2.15** |
| — 两个都不过 | 637 | 41.4% | 0.046 | 0.103 | 0.62 | 1.64 |
**关键是「毛R」列——那是扣任何成本之前的数字。** 浅色 0.155 vs 深色 0.933
**差 6 倍**。所以不存在「信号本来不错、被手续费吃掉」这回事:浅色的跟随性
本来就差。浅色扣完手续费只剩 **0.10bp**,任何滑点都会让它亏;逐币 8 个里
4 个已为负(ETH 1.98 / LTC 2.93 / DOGE 1.05 / XRP 0.14),最好的
ADA 也才 2.41bp。
**为什么肉眼看着有优势**:(1) 入场价确实是好的,信号标的是真实局部结构;
(2) 但价格不跟随,毛R 0.155 覆盖不了 2 ATR 的止损——**入场点好 ≠ 价格继续
朝你走**;(3) 44.3% 的胜率意味着近一半确实走对,扫图时看到的就是这些,
走错的当时形态一样好看。
⚠️ **新发现:1m 上阶梯过滤远比同向过滤重要,与 30m 相反。**
只过阶梯 +4.18bp,只过同向 **2.15bp 且毛R 为负**;而 30m 上同向单独就能把
PF 从 1.59 提到 1.85(§4 step30)。**两个过滤器的相对价值随级别翻转,
不要跨级别套用滤网结论。**
### 3.4 alpha 的来源(step32 消融)
逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位,
@@ -451,6 +656,8 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算
| 同一中枢重复入场(二次、三次) | 质量骤降,**只做首次** | step25 |
| 缠论引擎原生 `find_all_bsp` 的 B3/S3 | 统计上呈逆势、显著亏损:胜率 27.4%(低于 SL1.5/TP3.0 的随机基准 33%)、PF 0.66、t 18.76。**不是滞后造成的**——同一组过滤器把 B4 从 1.59 提到 2.26,对它无效(0.66→0.71),且入场后移 1~4 根只是平滑衰减。它几乎不筛(627 个中枢发 625 个信号),把「价格早已离开、再没回来」的历史区间也当信号发出来。B4/S4 因此单独立类,不是它的提前版 | step30/31 |
| 刷交易额换 VIP 费率 | 成本收益不划算,见 5.3。**但前提已变**:该结论算于「原始 taker 6bp、全 taker、TP=3.0、无门控」,四个前提现在全不成立(实际 2/0.8bp、混合费率、分批出场、有 ATR 门控)。已在返 50% 的档位上,继续压缩的空间本就有限,但**若要重估需重跑,不要直接引用旧结论** | step23 |
| **按 ATR 从大到小选币 / 追高 ATR 时刻** | 门控之上 ATR 高不加分。**币内严格单调递减**:ATR 最低 20% 分位净均R 1.040,最高 20% 只有 0.878。跨币看似正相关(r=+0.684)但**剔除 BTC 后掉到 +0.469**,其余 9 币极差仅 0.089。ATR 唯一真正给的是 bp 余量(机械关系)。且追高 ATR 会撞上流动性墙——现有 11 币全是头部流动性,外推到小市值币时多出的余量会被滑点吃回去。**ATR 是及格线不是排序键** | §3.33 |
| **`available_ts` 改取 `bis[2]`(中枢成立即可用)以消除右边缘重画** | 重画确实从 6.5% 降到 1.2%、滞后 2.16→2.01**但 alpha 被打成零**:实盘口径毛 R 0.933 → **0.000**(扣费前就没了 edge),PF 3.22→0.80,余量 15.07→−2.20bp,8/8 币全变差。中枢没发育完就下注,支撑/压力还没立住。**「等中枢最后一笔」就是 alpha 本身,重画是必付代价。**推论:任何以「让信号更早确定」为目标的改动,先测毛 R,不要只看重画率和滞后 | step47 §5.42 |
| 线段(`Chan_XD`)做大级别 | 滞后太大,且中枢极少 | 早期,用户也这么说 |
---
@@ -707,6 +914,106 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算
比 15m/30m 的 step38 结果还干净(那里有 0.7% 假阳性)。耗时对窗口严格线性
(约 0.105ms/根),所以没有任何理由带更长的历史。
⚠️ **「假阳性 0%」是 n=180 的结果,不足以排除 0.5% 量级的重画**
2026-08-28 更正)。若真实率为 0.5%,180 笔里期望仅出现 0.9 次——
**0% 与 0.5% 在该样本量下无法区分**
实测反例:web 端 BTC 1m(10 万根窗口),同一份数据分别截到本地 22:25 与
02:08 两个视角,212 个信号中 **1 个消失**08-27 22:19 的 B4,约 0.47%),
与 15m/30m 的 0.7% 同量级。用户在界面上先看到、后消失,即此现象。
**缓解事实:消失的那个 `htf_agree=False, ladder_ok=False`,是浅色 b4
本就不交易。** 真正要回答的是「**双过滤通过的深色信号会不会重画**」——
step39 报的「同向过滤也成立 100%」同样基于 n=180,同样不足以排除低频事件。
⚠️ **step39 的「假阳性」测法本身是无效的**:它从全序列随机抽非信号点,看时点
重建会不会凭空冒信号。1m 信号密度约 474 根 1 个,180 个随机点期望只撞上
0.38 个——**测出 0% 几乎不含信息量**。重画只发生在「差一点就成型」的结构附近,
不在随机点上。
#### 5.41 重画的根因:`available_ts` 取的是中枢**结束**而非**形成** ⚠️⚠️
代码审计(比逐根跑数据快得多):`find_fast_bsp3` 给定 `zones` 后严格因果
(只用 `close[j]` / `high[j-1]`),加未来K线动不了已成型的入场点。
**重画只可能来自 `zones` 表被修订。**
`zones_from_zs_list` 里:
```python
last_bi = bis[-1] # ← 中枢的最后一笔
avail = ts_of.get(sure_key) or ... # available_ts = 它的确认时刻
```
**用户 2026-08-28 指出的缠论定义问题**:中枢**形成**只需三笔有重叠区间;
**结束**才需要「离开且不返回的笔」确认。判信号只需形成,不必等结束。
`bis[-1]` 只有中枢走完才知道是哪一笔——**取的是结束时刻**。
实测(BTC 1m,30 万根,1651 个中枢):
| 中枢笔数 | 占比 |
|---|---|
| 恰好 3 笔 | 25.5% |
| **>3 笔** | **74.5%**(中位 5、均值 8.1、最大 89) |
**「最后一笔确认」比「第三笔确认」晚:中位 62 分钟、p75 142、p90 247、最大 1739。**
1m 上 62 分钟就是 62 根 K 线。
**三个后果:**
1. **这就是重画源。** 中枢每吸收一笔,`bis[-1]` 就后移一次。300 时点抽样、
3284 个「当时已生效」的中枢:完全一致 84.4%,**确认时刻被改 6.5%
(推后 198 个 vs 提前 17 个,中位 +100 分钟、p90 +413**,中枢消失 9.0%
阶梯翻转 2.6%。用户看到的 08-27 22:19 B4 消失即此。
2. **它解释了 docstring 里那个 34%。** 「627 个中枢只认 212 个(34%),被拒的
多数是『中枢确认时价格早已离开、此后再没回来』」——**「价格早已离开」不是
市场现象,是这个定义造出来的**:中枢正是被离开笔终结的,所以扫描起点天然
落在离开之后;代码又要求 `was_inside`,于是只能等价格**再次回到中枢再突破
一次**。那 66% 多半不是没机会,是第一次离开时还没开始看。
3. **方向对回测有利、对实盘不利。** 全量的 `available_ts` 系统性更晚 →
回测扫描起点比实盘晚 → **回测偏保守,不是被高估**;但实盘会产出更多、更早
的信号,这部分质量不在回测统计里。
**关键性质:`bis[2]` 是不变量,`bis[-1]` 不是。** 用户 2026-08-28 补充的
判断——「只有没确认的才会重画,确认的都不会」——与实测一致:6.5% 的改动
**不是已确认的笔被推翻**,而是中枢又吸收了新笔、`bis[-1]` 变成了另一根笔。
原来那根笔本身没变。而中枢一旦由三笔构成,第三笔就固定,不随延伸改变。
**A/B 实测(BTC 1m300 时点,3284 个已生效中枢):**
| 口径 | 完全一致 | 确认时刻被改 | 中枢消失 | 改动中位 |
|---|---|---|---|---|
| 现行 `bis[-1]`(中枢结束) | 84.4% | **6.5%** | 9.0% | +100 分 |
| 改用 `bis[2]`(中枢形成) | 89.7% | **1.2%** | 9.0% | +16 分 |
**`bis[2]` 把该重画源压到 1/5。** 两种口径全量中枢数都是 1651——纯粹改变
「何时可用」,不增删中枢。
**改成「第三笔确认」的预期收益**:消除该重画源(第三笔确认后不再变)、
符合缠论定义、滞后大幅下降(§3.2 已证滞后是收益第一驱动)。
⚠️ **剩下 9.0% 的「中枢消失」两种口径完全相同,是另一个源,与笔确认无关**
且在改用 `bis[2]` 后成为主要重画来源。最可能是**窗口左边界效应**:2000 根窗口
截断笔结构,造出全量视角里不存在的中枢。
⚠️⚠️ **由此牵出一个此前未记录的口径差:回测用全量历史建中枢,实盘用 2000 根
窗口。** 若那 9% 确由左边界造成,则它不是重画,而是**回测与实盘看到的中枢集合
本就不同**——比重画更根本。**待查。**
⚠️ **但不要直接改。** 这会换掉整个信号总体,本文档所有数字都绑定当前实现。
反向风险明确:中枢形成即可用意味着会去做「仍在形成中」的中枢的突破,
价格可能再回来——**信号变多但质量可能变差**。必须 A/B 实测。
> ⛔ **A/B 已跑完(step472026-08-28):`bis[2]` 作废,不要再试。**
> 详见 §5.42。它把重画压到 1/5 的同时,把毛 R 从 0.933 打到 −0.000——
> **alpha 归零,不是成本问题。** 下面这段「待办」保留仅作推理记录。
**(已完成)`available_ts = bis[2].sure_time` vs `bis[-1].sure_time` 的完整 A/B**
(信号数、滞后、PF、剔10%PF、滑点预算),以及深色信号的
大样本重画审计(脚本已写:`research/step45_repaint.py`)。
对实盘的影响有限但真实:实盘会比回测多开极少量的仓,这部分质量不在回测统计里。
**但出场(止损/止盈/超时)不依赖信号是否仍在图上,不会出现「信号消失、仓位卡住」。**
**这一步顺带解决了一个更重要的问题**1m 的回测口径本身是可信的,
`is_sure` 回撤对 1m 同样没有影响。**1m 现在唯一的风险就剩执行成本。**
@@ -721,10 +1028,164 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算
用 **WebSocket 订阅**而非轮询 REST 拉 K 线,并尽快把实盘迁到境外 VPS。
否则测到的滑点全是代理的账。
#### 5.42 A/B 判决:`bis[2]` 把 alpha 打成零,重画是必付代价(step47)⛔
8 个样本外币 × 30 万根 1m,两组共用同一个 TF_DF,只切 `available_ts` 的取法。
实盘口径(深色 ∧ ATR≥8bp):
| | `bis[-1]` 现行 | `bis[2]` 中枢成立 |
|---|---|---|
| 笔数 | 955 | 989 |
| 滞后 | 2.16 | 2.01 |
| 胜率 | 68.3% | 40.2% |
| **毛 R** | **0.933** | **0.000** |
| 净均 R | 0.798 | 0.147 |
| R 夏普 | 0.532 | 0.107 |
| PF | 3.22 | 0.80 |
| 剔10% PF | 2.35 | 0.55 |
| 滑点余量 | **15.07bp** | **2.20bp** |
**判决依据是「毛 R 0.933 → −0.000」这一行**:扣任何费用之前 edge 就没了,
所以不是成本、不是门控、不是出场参数的问题,是信号本身不再有预测力。
逐币 8/8 全部变差,只有 BTC 勉强为正(0.617 → 0.116)。
**为什么**`bis[2]` 只要三笔重叠就认为中枢可用,此时中枢尚未发育,
支撑/压力还没立住。`bis[-1]` 那段「等待」不是可以优化掉的延迟——**它就是 alpha 本身**。
由此得到一条一般性结论,值得推广到别的"降延迟"想法上:
> 右边缘重画不是 bug,是这个信号质量的**必付代价**。任何以「让信号更早确定」
> 为目标的改动,都要先测毛 R,**不能只测重画率或滞后**。
> §5.41 只测了稳定性就给出「唯一能同时改善收益与稳定性」的判断,是过早的。
顺带交叉验证:现行口径本次算出滑点余量 **15.07bp**,与 §5.3 里 2026 组合口径的
15.19bp 基本吻合——两条独立路径得到同一个数,该预算值可信。
脚本 `research/step47_avail_bi_ab.py`;开关在 `chanlun/analysis/fast_bsp.py`
`AVAIL_BI_INDEX`(环境变量 `CHAN_AVAIL_BI`),**默认 −1,保持现行口径**。
### 5.5 增量更新已验证:一致且快 12 倍(2026-08-28
`pipeline/builders/incremental.py` 此前一直标着「需重新验证」。已验完。
**一致性:400 根、每 20 根与全量重算对拍一次(共 20 次),
笔数 / 中枢 zg·zd / 信号 entry_idx 全部相同,无漂移。**
**耗时(本机 arm64 单线程,1m 2000 根窗口):**
| 环节 | 增量 | 全量重算 | |
|---|---|---|---|
| TF_DF / `append_bar` | 14.0ms | 192.5ms | |
| 中枢 + `find_fast_bsp3` | 7.6ms | 10.1ms | |
| **1m 侧合计** | **21.7ms** | 202.6ms | 9.3x |
| 5m 侧(`append_bar` 7.1 + 分型线 2.9 | 10.0ms/次 | 84.4ms | 每 5 根一次 → 摊 **2.0ms** |
| **完整一根** | **≈24ms** | **286ms** | **≈12x** |
⚠️ **必须定期重建窗口,否则增量的优势会被自己吃掉。**
`append_bar` 不是 O(1)——`pd.concat``add_indicators``cal_bi_list` 三处
仍是全量 O(n),而 dataframe 只增不减:
| | 1200 根后行数 | 中位 | 前100根 → 后100根 |
|---|---|---|---|
| 不裁剪 | 3200 | 16.3ms | 13.9 → **19.4+40%** |
| **每 500 根重建** | 2199 | 14.4ms | 14.2 → **14.0(持平)** |
`IncrementalBuilderMixin` 没有裁剪接口,做法是**每约 500 根用最近 2000 根
重新 `init_stream`**。单次约 192ms,摊到每根 0.4ms,可忽略。
**下一个数量级的线索(用户 2026-08-28 指出)**`add_indicators`
算了很多本策略用不到的指标。它在 `append_bar` 里是**每根全表重算**的,
所以浪费不是一次性的,而是每分钟一次、且随窗口长度线性放大。
改它会动到 `dataframe` 的列集合,web 与回测都依赖,需整体评估。
> 后续实测(见 §5.6)**推翻了这条线索的份量**`add_indicators` 只占全量
> 构建的 1.3%,talib 是向量化 C,便宜。真正的浪费在 `ChanKLU.set_indicators`
> 和 `ChanKLC.cal_all_ema_status`。方向对(确实有大量无用计算),位置错了。
**这是延迟这块唯一有数量级收益的方向**`find_fast_bsp3` 本身只占 0.75ms
(全链路的 0.3%),优化信号代码毫无意义;95% 的时间花在 TF_DF 从头重建
2000 根上,而实盘每分钟只新增 1 根——**重算了 1999 根没变的东西**。
> 服务器侧实测 1m 那条腿 695ms(本机同口径 202.6ms,慢 3.4x)。若两条腿
> 同比例,那边完整链路约 980ms,**已超 §6 定的 800ms lag 告警线,且尚未计
> 任何网络耗时**。改用增量后应降至约 82ms。⚠️ 该外推假设两条腿慢的比例一致,
> 服务器侧应直接实测一次完整链路。
>
> **§5.6 优化后本机复测(同口径 2001 根、单线程、从头算一遍)**:
>
> | 口径 | 优化前 | 现在 full | 现在 lean |
> |---|---|---|---|
> | 1m 腿 | 202.6ms | **71.8ms** | **50.0ms** |
> | 完整链路(两腿+信号+过滤) | 286ms | **178.4ms** | **120.5ms** |
> | 增量 `append_bar` | 24ms/根 | — | **13.6ms/根** |
>
> 按 3.4x 换算到服务器:完整链路 980ms → 约 **611ms(full) / 413ms(lean)**
> **即使完全不上增量也已落回 800ms 告警线以内**;上增量约 46ms。
> ⚠️ 但 3.4x 是在**优化前的代码**上量的。优化把工作从逐行 Python 对象操作挪到
> numpy 批量操作,若服务器慢在 CPU 主频则比值成立,若慢在内存带宽则未必。
> **服务器侧必须直接实测,不要只信这个换算。**
> 为什么这一步必要:step38 只验证了 15m/30m,从没验证 1m。而窗口 4000 根
> 对 15m 是 41 天、对 1m 只有 2.8 天,中枢的左边界效应完全不是一个量级。
> 窗口同时牵动两头:太短则中枢被截断信号不符,太长则算得慢延迟大滑点高。
### 5.6 引擎提速 2.6~3.6x,逐位一致(2026-08-28
先建了 `step46_engine_parity.py` 作为安全网:对 5 个用例(BTC 1m/5m、ETH 5m、
SOL 15m、XRP 30m,各 2 万根)固化 klc/笔/中枢/信号/被消费列的哈希。
**没有它不要碰引擎**——行为变化是静默的,不报错、不崩,只是信号悄悄换一批,
而 HANDOFF 全部数字都绑在当前实现上。
三处改动,每处都过了对拍:
| 改动 | 原因 | 效果 |
|---|---|---|
| `cal_kl_data` 预取 ndarray,不再 `df.iloc[i]` | 每根新建 40 列 Series + 在其上做几十次逐键查找 | 这一处就占构建的 **96%**2 万根 1946ms → 824ms |
| `ChanKLC.cal_all_ema_status` 改惰性 | 每 KLC 立即重算,占 25%,而**全仓无任何读取方** | 调用 14322 → 2248 次 |
| 删 `get_klc_list` 里的 `ema_up_list/ema_down_list` | 累加一整轮后直接丢弃,纯死代码 | — |
另外加了 `TF_DF(..., lean=True)`:只构建到中枢,跳过线段/走势中枢/整套 MACD
状态机(这些只服务 `bsp_list` 和 web 展示,笔与中枢不依赖)。
**已验证 lean 与 full 的笔/中枢/信号在 5 个用例上完全一致。**
结果(2 万根 5m):**full 1946 → 754ms2.6x),lean → 543ms3.6x**。
增量路径在两种模式下都仍与全量逐位一致;web API 全字段正常(full 模式一字未动)。
验证做了四层,每改一处都重跑:
1. **对拍基线**(上表所列全部哈希项),full 模式三处改动后全部一致
2. **lean ≡ full**:笔/中枢/信号在 5 个用例上完全相同
3. **增量路径**`init_stream` + `append_bar` 追加 150~200 根 vs 全量重建,
lean/full 两种模式都逐位一致
4. **web 冒烟**:真实 HTTP 请求,`bsp_list` 168 / `seg_list` 177 / `zs_list` 17 /
`klc_trend` 齐全
**大样本对拍(20 万根 × 5 用例)**:用 `git stash` 切回改动前的原始代码存基线,
再切回优化版对拍,**逐位一致**。这是真正的改前/改后比对,不是自己跟自己比。
同一批工作量 **99.9s → 43.3sfull 模式 2.3x**,与 2 万根时的 2.6x 一致,
说明提速不随规模衰减。复现:
```bash
git stash push -- chanlun/
python step46_engine_parity.py --save --rows 200000 --out step46_baseline_big.json
git stash pop
python step46_engine_parity.py --check --rows 200000 --out step46_baseline_big.json
```
关键认识订正:
- **缩放是线性的,不是 O(n²)**。中途一度量到"数据 6 倍、耗时 44 倍",是我自己
开的 `tracemalloc` 污染了计时。干净重测每翻倍 ×2.06~2.2016 万根 lean 4.2s。
- **`add_indicators` 只占 1.3%**,删无用指标在这里几乎省不到时间。
它有 11 个列(`bbup365`/`bblow30`/`bbp302` 等)Python 与前端都无人读取,
但它们不进 KLU,唯一成本是 web 响应体积(约占 45 列中的 11 列)。
- 实盘延迟已不是瓶颈:`append_bar` 约 32ms/根,对 800ms 告警线有 25 倍余量。
**继续优化只对研究吞吐有意义。**
剩余热点(lean、8 万根口径):`cal_trend``set_indicators_from` 各约 0.36~0.59s。
`cal_trend` 不能跳过——`bi.py:221``klc.trend`,笔的计算依赖它;
它带序列状态(`last_trend` + 近 5 根窗口),向量化风险高,收益约 20%,暂不做。
---
## 6. 接下来要做的事(按优先级)
@@ -1076,6 +1537,21 @@ API 限流风险隔离三个好处。
- [x] **确认交易所与框架** → Bitget + Hummingbot,先只上 1m
- [x] **测执行延迟** → 上游连接器 bug 已定位并修好,补丁后 lag 506~642ms、
无条件漂移 0.50~0.87bp,占预算 6%。见 §6 第 2 步
- [ ] **实盘信号计算改用增量**(§5.5,已验证一致且快 12x286ms → 24ms)。
**配套必须做窗口裁剪**:每约 500 根用最近 2000 根重新 `init_stream`
否则 `append_bar` 的 O(n) 部分会让耗时持续爬升(1200 根后 +40%)
- [ ] **`available_ts` 改用 `bis[2]`(中枢形成)而非 `bis[-1]`(中枢结束)的
完整 A/B** —— ✅ **已完成,结论是不换**step47,见 §5.42)。
当时预判的「反向风险:会去做仍在形成中的中枢的突破,质量可能变差」
**正是实际发生的事**,且比预期严重:毛 R 0.933 → −0.0008/8 币全变差。
**这条已关闭,不要重开。**
- [ ] **查那 9.0% 的「中枢消失」是不是窗口左边界效应**(§5.41)。若是,则
「回测用全量历史建中枢、实盘用 2000 根窗口」是比重画更根本的口径差。
注意这条与 `bis[2]` 无关,**没有被 step47 否掉**,仍然要查
- [ ] **深色信号大样本重画审计**,脚本已写:`research/step45_repaint.py`
(逐根时点重建,替换掉 step39 那个随机抽点的无效测法)。
§5.42 之后这条的意义变了:不再是为了「修掉重画」,而是为了**量化实盘
会多开多少仓、那部分的质量如何**——重画本身已确认是必付代价
- [ ] **写 V2 controller + 盘口缓冲,跑影子测量**(当前第一件事)。
字段与统计口径见 §6 第 3 步,**三条硬要求别漏**:只统计过滤后的信号根、
条件漂移与无条件漂移分开报、出场腿按 maker/taker 分开统计
+487
View File
@@ -0,0 +1,487 @@
{
"BTC_1m": {
"n_rows": 20000,
"n_klu": 20000,
"n_klc": 12496,
"n_bi": 913,
"n_seg": 131,
"n_bi_zs": 114,
"n_zs": 16,
"n_bsp": 0,
"klc_high": "986038d3e673336e",
"klc_low": "ee349f689ebfe387",
"klc_fx": "b323365a81e5e666",
"bi_start": "f829c2ce6845f569",
"bi_end": "f829c2ce6845f569",
"bi_sure": "64458b0f3100544e",
"n_zones": 114,
"zone_zg": "00c54276171344e0",
"zone_zd": "e0dcb978a1105305",
"zone_avail": "06d947c90bbab2f7",
"zone_ladder": "c6312a30d149c5b8",
"n_sig": 31,
"sig_entry_idx": "117daf6e8c55678c",
"sig_direction": "82ee301abc35d11b",
"sig_bo_idx": "37ffa946fad579ce",
"sig_pb_idx": "8552ade1f84eb3d3",
"sig_lag": "da1bcee041ab9685",
"sig_depth": "e0e60c3d319358e3",
"sig_zone_i": "434c25ef783c3d1d",
"col_open": "3197bbad7b435c9f",
"col_high": "41e4ad11f6f6d786",
"col_low": "c309926c23dfa574",
"col_close": "c48f76a626b629c6",
"col_volume": "07f9f254f17399a0",
"col_atr": "074464dfb5648113",
"col_macd": "135a69f5aa1df7dc",
"col_macdsignal": "62ce36e9da9f37ec",
"col_macdhist": "61ddcf3bb139bb81",
"col_ema5": "e0ea01077a59b84b",
"col_ema13": "ab9a31bbf5de8b57",
"col_ema24": "44e8e295560b85cf",
"col_ema26": "a710c87eeec607ea",
"col_ema52": "578454b2d9147d96",
"col_ema104": "36649911c3df0136",
"col_ema156": "bba5874bd409fa90",
"col_ema208": "a0bc2fa3e690681c",
"col_ema7": "e1e76d337b9139e4",
"col_rsi": "cc09f5884225b37f",
"col_volume_ratio": "2382df013004571f",
"col_bb2633upper": "85839e5a7bb36dc7",
"col_bb2633lower": "13861ca562994024",
"col_bb2633middle": "bf3c9bd564c2e8d7",
"col_bbp30": "d00623b2d18c6653",
"col_bbp120": "36dd361b5c618dcc",
"col_bbp365": "5cd3e7d640c7d5dd",
"_all_columns": [
"atr",
"bb2633lower",
"bb2633middle",
"bb2633upper",
"bblow120",
"bblow30",
"bblow302",
"bblow365",
"bbmiddle30",
"bbp120",
"bbp2633",
"bbp30",
"bbp302",
"bbp365",
"bbup120",
"bbup30",
"bbup302",
"bbup365",
"close",
"date",
"ema10",
"ema104",
"ema13",
"ema156",
"ema208",
"ema24",
"ema26",
"ema5",
"ema52",
"ema7",
"high",
"low",
"macd",
"macdhist",
"macdsignal",
"open",
"rsi",
"timestamp",
"volume",
"volume_ratio"
]
},
"BTC_5m": {
"n_rows": 20000,
"n_klu": 20000,
"n_klc": 14323,
"n_bi": 974,
"n_seg": 149,
"n_bi_zs": 115,
"n_zs": 15,
"n_bsp": 0,
"klc_high": "0d3d5dcb6be9d8cf",
"klc_low": "2ae0ce7158f99ee5",
"klc_fx": "db8c606c09803b39",
"bi_start": "eb36252ef42df30d",
"bi_end": "eb36252ef42df30d",
"bi_sure": "a61c15678945f9e1",
"n_zones": 115,
"zone_zg": "5eaa2f8f738c8566",
"zone_zd": "d48ee940dac1fdba",
"zone_avail": "bfc273ac2c80a856",
"zone_ladder": "822ab80c13164fcf",
"n_sig": 48,
"sig_entry_idx": "4ca6e0b3ad9650db",
"sig_direction": "74de66c775f3159f",
"sig_bo_idx": "020d83285f111211",
"sig_pb_idx": "c9d29e0e1cf23857",
"sig_lag": "630718fda8c18f8b",
"sig_depth": "cbe22a059a935417",
"sig_zone_i": "499d9a4ffc4e4fac",
"col_open": "da5240f8ff94b7d3",
"col_high": "c06a481db2faee01",
"col_low": "07108f56862e97f8",
"col_close": "878c50482e134764",
"col_volume": "e57b8dbfb3589a2a",
"col_atr": "2e57310f4bb4f259",
"col_macd": "807220fb465a46f1",
"col_macdsignal": "079fd0757f9a6040",
"col_macdhist": "2372736ddaa73786",
"col_ema5": "5f7cc824e469e468",
"col_ema13": "be17db658716e40f",
"col_ema24": "dae2098cf4b4a029",
"col_ema26": "1d6e667de928a8b4",
"col_ema52": "a9521436966fff79",
"col_ema104": "1d1373a8c3e48cd9",
"col_ema156": "679d2e502207f085",
"col_ema208": "8790e0e5c50bfbc3",
"col_ema7": "b631eeb99b06b174",
"col_rsi": "f1b3f6541bcefdc9",
"col_volume_ratio": "3db5f52fe7051017",
"col_bb2633upper": "cbd0d954047104f5",
"col_bb2633lower": "e1a83624de00b90c",
"col_bb2633middle": "a2a24306ccdc04c4",
"col_bbp30": "453235723fa5bc76",
"col_bbp120": "f67bad6c333f35b7",
"col_bbp365": "d68b613efed41cd6",
"_all_columns": [
"atr",
"bb2633lower",
"bb2633middle",
"bb2633upper",
"bblow120",
"bblow30",
"bblow302",
"bblow365",
"bbmiddle30",
"bbp120",
"bbp2633",
"bbp30",
"bbp302",
"bbp365",
"bbup120",
"bbup30",
"bbup302",
"bbup365",
"close",
"date",
"ema10",
"ema104",
"ema13",
"ema156",
"ema208",
"ema24",
"ema26",
"ema5",
"ema52",
"ema7",
"high",
"low",
"macd",
"macdhist",
"macdsignal",
"open",
"rsi",
"timestamp",
"volume",
"volume_ratio"
]
},
"ETH_5m": {
"n_rows": 20000,
"n_klu": 20000,
"n_klc": 14342,
"n_bi": 927,
"n_seg": 142,
"n_bi_zs": 100,
"n_zs": 15,
"n_bsp": 0,
"klc_high": "131ed2d2cc245008",
"klc_low": "d53c72dc569bbd4f",
"klc_fx": "ec3177b225143a7a",
"bi_start": "fb708274964722c2",
"bi_end": "fb708274964722c2",
"bi_sure": "b7b4cb502f2a0d70",
"n_zones": 100,
"zone_zg": "dca84f6a299fd1aa",
"zone_zd": "d462f78cc4b7538b",
"zone_avail": "b92e97fc130e310d",
"zone_ladder": "cfc5c84c6a6d90f2",
"n_sig": 42,
"sig_entry_idx": "82e50dda58170b06",
"sig_direction": "c7e63a0158b14ef2",
"sig_bo_idx": "d9c826c620ff6bf2",
"sig_pb_idx": "25340fc752a8143c",
"sig_lag": "a6fa56f9f5b5f367",
"sig_depth": "71d0776582fad395",
"sig_zone_i": "f87a3a29b6b26adb",
"col_open": "4875825277eb22d2",
"col_high": "efc10b5252481a18",
"col_low": "c2978c8eb3cdf78c",
"col_close": "e0bc2b374fa5b33d",
"col_volume": "f97c30ec4bb0ecf8",
"col_atr": "8d6f2061d285ad98",
"col_macd": "c8fc7f49467985ee",
"col_macdsignal": "b6af44d80ea485c1",
"col_macdhist": "fa854fe5a2055930",
"col_ema5": "c6f2736ca8f67a11",
"col_ema13": "2103d16e3db9dff7",
"col_ema24": "e72b6c1ac9118ebd",
"col_ema26": "a83023c8930a807d",
"col_ema52": "ce2971fa029ee7b7",
"col_ema104": "3cbe3efc7b6d8457",
"col_ema156": "84e140907ef54b4c",
"col_ema208": "e28316d21c6d29e7",
"col_ema7": "5bbf9ce6610e1574",
"col_rsi": "c4c6ff1145c88981",
"col_volume_ratio": "bfb7636b8d92fca1",
"col_bb2633upper": "f0068bec633e28d4",
"col_bb2633lower": "78936103ef228327",
"col_bb2633middle": "3b4a8fd306318760",
"col_bbp30": "225773d67ca05c7e",
"col_bbp120": "1084331d91660254",
"col_bbp365": "ea71e0285f2801ba",
"_all_columns": [
"atr",
"bb2633lower",
"bb2633middle",
"bb2633upper",
"bblow120",
"bblow30",
"bblow302",
"bblow365",
"bbmiddle30",
"bbp120",
"bbp2633",
"bbp30",
"bbp302",
"bbp365",
"bbup120",
"bbup30",
"bbup302",
"bbup365",
"close",
"date",
"ema10",
"ema104",
"ema13",
"ema156",
"ema208",
"ema24",
"ema26",
"ema5",
"ema52",
"ema7",
"high",
"low",
"macd",
"macdhist",
"macdsignal",
"open",
"rsi",
"timestamp",
"volume",
"volume_ratio"
]
},
"SOL_15m": {
"n_rows": 20000,
"n_klu": 20000,
"n_klc": 14051,
"n_bi": 894,
"n_seg": 131,
"n_bi_zs": 115,
"n_zs": 7,
"n_bsp": 0,
"klc_high": "6514126fb15a2495",
"klc_low": "1a95961cd8510b61",
"klc_fx": "39a6f1e8cbe15296",
"bi_start": "93d17db2c0726ad2",
"bi_end": "93d17db2c0726ad2",
"bi_sure": "72a7eeed9f203249",
"n_zones": 115,
"zone_zg": "bdddd8960357e2c2",
"zone_zd": "a72a80f2ec747fa9",
"zone_avail": "d6bec0bead4ca986",
"zone_ladder": "4bbd1a9e4080a3d4",
"n_sig": 33,
"sig_entry_idx": "57154349f7256bd5",
"sig_direction": "3456806b7f3f915a",
"sig_bo_idx": "d3a1e9433c85a1d0",
"sig_pb_idx": "7584cbbe94a65ea5",
"sig_lag": "0d9429413d5a72ba",
"sig_depth": "5fefacd49949cd9f",
"sig_zone_i": "99844be679dbc613",
"col_open": "0c38967f4d88ca2f",
"col_high": "9d506e4c74c1d73c",
"col_low": "416ac69aa8a7a5c4",
"col_close": "27579fe4a877d720",
"col_volume": "a8c30f4f5a732e91",
"col_atr": "366c4ec27cd52632",
"col_macd": "449587b2c37e8df2",
"col_macdsignal": "83de7bfbe9224c61",
"col_macdhist": "e94458f1558bd910",
"col_ema5": "cd13382ef7069033",
"col_ema13": "6a9429bffb033b9a",
"col_ema24": "41a037d614e701ba",
"col_ema26": "c46e3a74f471bbb0",
"col_ema52": "bf8d0b1208647291",
"col_ema104": "4f229c578d7998e4",
"col_ema156": "1fd8a8c38d11f081",
"col_ema208": "3e2704d5c53ee9f8",
"col_ema7": "21281407046b4322",
"col_rsi": "a800c451254f433e",
"col_volume_ratio": "ec05ddc04f54b31b",
"col_bb2633upper": "9c0bb9a91102bcd9",
"col_bb2633lower": "b55705f39cdb0c63",
"col_bb2633middle": "365c7763ddad555c",
"col_bbp30": "8ab505323d0cb0a9",
"col_bbp120": "8a16100ed6fe37b6",
"col_bbp365": "437b91a2cdde91eb",
"_all_columns": [
"atr",
"bb2633lower",
"bb2633middle",
"bb2633upper",
"bblow120",
"bblow30",
"bblow302",
"bblow365",
"bbmiddle30",
"bbp120",
"bbp2633",
"bbp30",
"bbp302",
"bbp365",
"bbup120",
"bbup30",
"bbup302",
"bbup365",
"close",
"date",
"ema10",
"ema104",
"ema13",
"ema156",
"ema208",
"ema24",
"ema26",
"ema5",
"ema52",
"ema7",
"high",
"low",
"macd",
"macdhist",
"macdsignal",
"open",
"rsi",
"timestamp",
"volume",
"volume_ratio"
]
},
"XRP_30m": {
"n_rows": 20000,
"n_klu": 20000,
"n_klc": 14763,
"n_bi": 849,
"n_seg": 125,
"n_bi_zs": 108,
"n_zs": 11,
"n_bsp": 0,
"klc_high": "567e7651c968c00c",
"klc_low": "c1b8189a9d447452",
"klc_fx": "67b72deec2a5593b",
"bi_start": "04f30102940f974d",
"bi_end": "04f30102940f974d",
"bi_sure": "da571f5278b770e1",
"n_zones": 108,
"zone_zg": "1eefb80dd6c76a53",
"zone_zd": "8fa0d96149ddc204",
"zone_avail": "7e652fb397da32da",
"zone_ladder": "4177c4b5a40b6247",
"n_sig": 40,
"sig_entry_idx": "d550aee46e46c917",
"sig_direction": "81f88af0167a498c",
"sig_bo_idx": "f704a273ed48013f",
"sig_pb_idx": "2e030812f5243076",
"sig_lag": "1c1eebf161a0ff83",
"sig_depth": "c671af2668c0cc78",
"sig_zone_i": "49971affbaf93434",
"col_open": "e064238e9f5e7baa",
"col_high": "a28e55a860c236ce",
"col_low": "7585fe470cd2ecfd",
"col_close": "3470d49402aa5a5e",
"col_volume": "0b473a543b78cd89",
"col_atr": "967be1c16f118183",
"col_macd": "cf3b1cc2d9f3ca98",
"col_macdsignal": "993858809b2f3a2e",
"col_macdhist": "469650fc16672829",
"col_ema5": "0c7e735c6abfc0b5",
"col_ema13": "bc9799632b2232f5",
"col_ema24": "9397472a7982e2e4",
"col_ema26": "59ec2e87acb64341",
"col_ema52": "f61f6de2169678b8",
"col_ema104": "fe786b2dc9436f25",
"col_ema156": "1291ef40a77682d5",
"col_ema208": "7e63bf51454a5dfb",
"col_ema7": "25983717dea0f463",
"col_rsi": "b992c428c2643962",
"col_volume_ratio": "4d70f107c08380cc",
"col_bb2633upper": "cbe0b203046d7377",
"col_bb2633lower": "166c968182153faf",
"col_bb2633middle": "7ba495bfe685d0eb",
"col_bbp30": "598150e822aac882",
"col_bbp120": "36778d36238896c4",
"col_bbp365": "a1f88436a790dc2a",
"_all_columns": [
"atr",
"bb2633lower",
"bb2633middle",
"bb2633upper",
"bblow120",
"bblow30",
"bblow302",
"bblow365",
"bbmiddle30",
"bbp120",
"bbp2633",
"bbp30",
"bbp302",
"bbp365",
"bbup120",
"bbup30",
"bbup302",
"bbup365",
"close",
"date",
"ema10",
"ema104",
"ema13",
"ema156",
"ema208",
"ema24",
"ema26",
"ema5",
"ema52",
"ema7",
"high",
"low",
"macd",
"macdhist",
"macdsignal",
"open",
"rsi",
"timestamp",
"volume",
"volume_ratio"
]
}
}
+487
View File
@@ -0,0 +1,487 @@
{
"BTC_1m": {
"n_rows": 200000,
"n_klu": 200000,
"n_klc": 131701,
"n_bi": 9504,
"n_seg": 1406,
"n_bi_zs": 1073,
"n_zs": 155,
"n_bsp": 0,
"klc_high": "992af879db2e8e43",
"klc_low": "8ba4f68b829b552c",
"klc_fx": "03870041e7b334e7",
"bi_start": "3d8fbede022d751d",
"bi_end": "3d8fbede022d751d",
"bi_sure": "bd74890c6552b345",
"n_zones": 1073,
"zone_zg": "0436269233f63984",
"zone_zd": "5b3cc3b0b0eafd20",
"zone_avail": "b45174f22f908d5b",
"zone_ladder": "1342f5e104d399fa",
"n_sig": 396,
"sig_entry_idx": "c9728ba1d4a739c5",
"sig_direction": "8bc66cd2f6c0ffa8",
"sig_bo_idx": "2981b0d3134b99cc",
"sig_pb_idx": "21ec7c5b1f85cca4",
"sig_lag": "2f5640376a99c4ae",
"sig_depth": "cf3f0bf3d239c2f9",
"sig_zone_i": "8654e213e0faaa5f",
"col_open": "32e16cc827550cce",
"col_high": "9c9cb56b72814b2b",
"col_low": "4db855614f0f3254",
"col_close": "3707a170acb64306",
"col_volume": "4e3eb344a25eeec2",
"col_atr": "c6e1a3b27bfaec47",
"col_macd": "94a485845cf30bea",
"col_macdsignal": "87ec0074d9bc8cbc",
"col_macdhist": "5127835e6b590aff",
"col_ema5": "5f4851ec918152d5",
"col_ema13": "5ede020d68cde473",
"col_ema24": "dc45445e03565a7d",
"col_ema26": "7ffc3a0799531172",
"col_ema52": "dafc5dbfdac7ec7d",
"col_ema104": "6a5911c3a9daead3",
"col_ema156": "f7aa34a1c04ecd66",
"col_ema208": "773341a5170f61ee",
"col_ema7": "25b47f9884bc7eff",
"col_rsi": "d07bc07917c51b98",
"col_volume_ratio": "2b3cbd8c2fd4a0c7",
"col_bb2633upper": "8abeb464d97ed502",
"col_bb2633lower": "54f96c202ecd17c2",
"col_bb2633middle": "bf7a068c7592c379",
"col_bbp30": "e6b3f087308509bd",
"col_bbp120": "eb53451f0dadd779",
"col_bbp365": "c32489b1e8216c96",
"_all_columns": [
"atr",
"bb2633lower",
"bb2633middle",
"bb2633upper",
"bblow120",
"bblow30",
"bblow302",
"bblow365",
"bbmiddle30",
"bbp120",
"bbp2633",
"bbp30",
"bbp302",
"bbp365",
"bbup120",
"bbup30",
"bbup302",
"bbup365",
"close",
"date",
"ema10",
"ema104",
"ema13",
"ema156",
"ema208",
"ema24",
"ema26",
"ema5",
"ema52",
"ema7",
"high",
"low",
"macd",
"macdhist",
"macdsignal",
"open",
"rsi",
"timestamp",
"volume",
"volume_ratio"
]
},
"BTC_5m": {
"n_rows": 200000,
"n_klu": 200000,
"n_klc": 146735,
"n_bi": 9670,
"n_seg": 1485,
"n_bi_zs": 1093,
"n_zs": 160,
"n_bsp": 0,
"klc_high": "dc921e6668a807e9",
"klc_low": "47a5508f3736f23b",
"klc_fx": "4a459666e121fee2",
"bi_start": "12144ccb870d12a1",
"bi_end": "12144ccb870d12a1",
"bi_sure": "0ee81c335c3c858e",
"n_zones": 1093,
"zone_zg": "54918583a2371b0f",
"zone_zd": "cb698e8570e2b3fc",
"zone_avail": "70a78276ce8d81e8",
"zone_ladder": "4eaf9f4810737d9b",
"n_sig": 396,
"sig_entry_idx": "c166fe6bbec08d80",
"sig_direction": "d376c8684119da87",
"sig_bo_idx": "1963ec7608066cf6",
"sig_pb_idx": "c7db54ba1103fe3b",
"sig_lag": "8cfbb30f7e26bcff",
"sig_depth": "fbc0589ca7908cee",
"sig_zone_i": "aa0a7f54921bfdb2",
"col_open": "b0ef7c7ed1122a9b",
"col_high": "c036264627035105",
"col_low": "aad6caaa73079e29",
"col_close": "00ccedeb197fd137",
"col_volume": "11686a1bed642a1f",
"col_atr": "1fa43f86914c4a8e",
"col_macd": "ad66e34357a586c7",
"col_macdsignal": "3b9a127b992897b3",
"col_macdhist": "c564a280ef8eb0f1",
"col_ema5": "74e476c0269798a9",
"col_ema13": "f6968ae7f127ebe2",
"col_ema24": "db3ae3b801d846f5",
"col_ema26": "adda6c2af103a6ed",
"col_ema52": "4d663556702cd4df",
"col_ema104": "c14c8d69fa4be179",
"col_ema156": "9e5df963de200c54",
"col_ema208": "30e0eabc1ef6e4fb",
"col_ema7": "a78d7798f10638fc",
"col_rsi": "c4dc1ca7d74b64f9",
"col_volume_ratio": "62694ace333f324a",
"col_bb2633upper": "35149f0ee32941d5",
"col_bb2633lower": "ce61849352496a88",
"col_bb2633middle": "62cf973be0c1d775",
"col_bbp30": "d8dc8265680ed17c",
"col_bbp120": "d951e014ae714540",
"col_bbp365": "a4f5700a169eed20",
"_all_columns": [
"atr",
"bb2633lower",
"bb2633middle",
"bb2633upper",
"bblow120",
"bblow30",
"bblow302",
"bblow365",
"bbmiddle30",
"bbp120",
"bbp2633",
"bbp30",
"bbp302",
"bbp365",
"bbup120",
"bbup30",
"bbup302",
"bbup365",
"close",
"date",
"ema10",
"ema104",
"ema13",
"ema156",
"ema208",
"ema24",
"ema26",
"ema5",
"ema52",
"ema7",
"high",
"low",
"macd",
"macdhist",
"macdsignal",
"open",
"rsi",
"timestamp",
"volume",
"volume_ratio"
]
},
"ETH_5m": {
"n_rows": 200000,
"n_klu": 200000,
"n_klc": 147071,
"n_bi": 9668,
"n_seg": 1447,
"n_bi_zs": 1064,
"n_zs": 151,
"n_bsp": 0,
"klc_high": "b474c10434b7d1be",
"klc_low": "16c8b368522eff67",
"klc_fx": "454f30149c5437e9",
"bi_start": "e962ff5f90072953",
"bi_end": "e962ff5f90072953",
"bi_sure": "6702ee0ec3ba05b7",
"n_zones": 1064,
"zone_zg": "4f8fdc5ce3699a83",
"zone_zd": "55f8826e3850bbbb",
"zone_avail": "b4af5d59909c20de",
"zone_ladder": "7718718df232fbb6",
"n_sig": 357,
"sig_entry_idx": "6de39296c88fcbb5",
"sig_direction": "62e7be93232d7c6d",
"sig_bo_idx": "6ec163434edaf6e9",
"sig_pb_idx": "ca9b4000a3d8b167",
"sig_lag": "16e524f721f0a3aa",
"sig_depth": "bd78d76d5ac56d18",
"sig_zone_i": "ec18317accf8f34f",
"col_open": "6f693fe747fe72ea",
"col_high": "e5cf5f170c1b74c5",
"col_low": "9a21e97b26b281ee",
"col_close": "c65c480ea3acb4cf",
"col_volume": "deca090b35e34611",
"col_atr": "5c598706876091c4",
"col_macd": "3f38452148fbb5db",
"col_macdsignal": "848e9ea874b125cb",
"col_macdhist": "3e2301728b8c73ae",
"col_ema5": "e66614435995ca8c",
"col_ema13": "5b90f189ecb9dbde",
"col_ema24": "dc98f535eb5b9a14",
"col_ema26": "98ce247c33012910",
"col_ema52": "ce2d49aa246f7d1b",
"col_ema104": "37a07762a9d5bfe1",
"col_ema156": "1040f54fb1f1a28c",
"col_ema208": "2430fa3290e62444",
"col_ema7": "e85e8582b2dd352a",
"col_rsi": "f3d09ac8721406bd",
"col_volume_ratio": "5842f57c75a5c5a8",
"col_bb2633upper": "1e1622aad5a3dc46",
"col_bb2633lower": "58392bfb6e652321",
"col_bb2633middle": "72376e41d5a46393",
"col_bbp30": "1b99ed1cb7132665",
"col_bbp120": "b11c01bd8702106e",
"col_bbp365": "9613ed3ed332a0e2",
"_all_columns": [
"atr",
"bb2633lower",
"bb2633middle",
"bb2633upper",
"bblow120",
"bblow30",
"bblow302",
"bblow365",
"bbmiddle30",
"bbp120",
"bbp2633",
"bbp30",
"bbp302",
"bbp365",
"bbup120",
"bbup30",
"bbup302",
"bbup365",
"close",
"date",
"ema10",
"ema104",
"ema13",
"ema156",
"ema208",
"ema24",
"ema26",
"ema5",
"ema52",
"ema7",
"high",
"low",
"macd",
"macdhist",
"macdsignal",
"open",
"rsi",
"timestamp",
"volume",
"volume_ratio"
]
},
"SOL_15m": {
"n_rows": 200000,
"n_klu": 200000,
"n_klc": 147387,
"n_bi": 9221,
"n_seg": 1443,
"n_bi_zs": 1097,
"n_zs": 153,
"n_bsp": 0,
"klc_high": "9873e17761a5f439",
"klc_low": "c26a95692a503700",
"klc_fx": "e9da3d4ecae9b7d6",
"bi_start": "7ae8f7ca13826214",
"bi_end": "7ae8f7ca13826214",
"bi_sure": "4dc74bb88bebe27b",
"n_zones": 1097,
"zone_zg": "4ac63d81a0c7f518",
"zone_zd": "38d5b7afd33e2e15",
"zone_avail": "532fd21eed5584f7",
"zone_ladder": "6139c6b7503d0c44",
"n_sig": 371,
"sig_entry_idx": "d74faae30a9c9d49",
"sig_direction": "038ed1f04dbe7141",
"sig_bo_idx": "9c2251f45e7b31f6",
"sig_pb_idx": "ac324f507086aec8",
"sig_lag": "b429021aa0a4d4da",
"sig_depth": "654d5249825e9903",
"sig_zone_i": "f070e3f31075cb19",
"col_open": "1c9998e987e16a5f",
"col_high": "80ed4bf62274de03",
"col_low": "09b8b6c12e4e3f06",
"col_close": "223ae3e7e1b7d05a",
"col_volume": "f311c91d123a55b5",
"col_atr": "ed2d754bd3b15f77",
"col_macd": "290ea71faef859e4",
"col_macdsignal": "70008a039281deb8",
"col_macdhist": "16535492810b17e8",
"col_ema5": "c2b75b157fee520a",
"col_ema13": "a38553a7296f8c14",
"col_ema24": "aa12c0c0fc8b839f",
"col_ema26": "2e04f45e2f2067fb",
"col_ema52": "361bd4886eaf510d",
"col_ema104": "beca8cd07bb88835",
"col_ema156": "941a4949be08fcbd",
"col_ema208": "82f3b64ae8e6c97c",
"col_ema7": "466b0319cd174bb2",
"col_rsi": "cd3eb3fa8896a0e6",
"col_volume_ratio": "c4d3a5bf0a821829",
"col_bb2633upper": "3599736cc0c8ed31",
"col_bb2633lower": "4afcdb806d790258",
"col_bb2633middle": "fea10757ba7fdeb6",
"col_bbp30": "64222d075acd186e",
"col_bbp120": "d17f37169b2a5966",
"col_bbp365": "943089e90d9da3ba",
"_all_columns": [
"atr",
"bb2633lower",
"bb2633middle",
"bb2633upper",
"bblow120",
"bblow30",
"bblow302",
"bblow365",
"bbmiddle30",
"bbp120",
"bbp2633",
"bbp30",
"bbp302",
"bbp365",
"bbup120",
"bbup30",
"bbup302",
"bbup365",
"close",
"date",
"ema10",
"ema104",
"ema13",
"ema156",
"ema208",
"ema24",
"ema26",
"ema5",
"ema52",
"ema7",
"high",
"low",
"macd",
"macdhist",
"macdsignal",
"open",
"rsi",
"timestamp",
"volume",
"volume_ratio"
]
},
"XRP_30m": {
"n_rows": 116374,
"n_klu": 116374,
"n_klc": 82461,
"n_bi": 5109,
"n_seg": 784,
"n_bi_zs": 572,
"n_zs": 74,
"n_bsp": 0,
"klc_high": "2f214dccb66c2acb",
"klc_low": "11763813065965dc",
"klc_fx": "16a8abb38cc08276",
"bi_start": "ddb3716c5603fc9f",
"bi_end": "ddb3716c5603fc9f",
"bi_sure": "45d38e2edf89a78e",
"n_zones": 572,
"zone_zg": "6301d2ba0529348a",
"zone_zd": "02798be778a12b3c",
"zone_avail": "f7627b9e187434f8",
"zone_ladder": "79b4eba53b7f16b4",
"n_sig": 185,
"sig_entry_idx": "3fd118bcca9c0318",
"sig_direction": "4e5c8a66c981612e",
"sig_bo_idx": "359749625e049f17",
"sig_pb_idx": "30d938988a47152e",
"sig_lag": "6ecaeb56a6377c02",
"sig_depth": "b472e1cc5387f001",
"sig_zone_i": "beb04af56f37bf08",
"col_open": "d8eb9bfb0cb6375a",
"col_high": "c8305b04a6736acf",
"col_low": "b00124fab8245af3",
"col_close": "69e3cdd2e55f4809",
"col_volume": "839836338744c27c",
"col_atr": "5900706833e9edba",
"col_macd": "88f65443d79c489f",
"col_macdsignal": "f1fd2dcea6c73575",
"col_macdhist": "fce95fc9ccb6b57e",
"col_ema5": "5c6b418babd70b93",
"col_ema13": "4775865a25779b16",
"col_ema24": "f24136710438fa4e",
"col_ema26": "c8a14eab58bc38dc",
"col_ema52": "602f9cd13a369f59",
"col_ema104": "aefabaf6c26d4977",
"col_ema156": "a09b1eb8b3435f96",
"col_ema208": "861be94bc86d83b8",
"col_ema7": "7e605fb6cd8fa03f",
"col_rsi": "ab9617b826fb1cf1",
"col_volume_ratio": "adeab54de59ff72c",
"col_bb2633upper": "a4e7709df8bebb48",
"col_bb2633lower": "e62b8f228e730261",
"col_bb2633middle": "5799755b2c60e9fe",
"col_bbp30": "a404273b177e495e",
"col_bbp120": "46c63a30348ad94e",
"col_bbp365": "f974d0696160d78d",
"_all_columns": [
"atr",
"bb2633lower",
"bb2633middle",
"bb2633upper",
"bblow120",
"bblow30",
"bblow302",
"bblow365",
"bbmiddle30",
"bbp120",
"bbp2633",
"bbp30",
"bbp302",
"bbp365",
"bbup120",
"bbup30",
"bbup302",
"bbup365",
"close",
"date",
"ema10",
"ema104",
"ema13",
"ema156",
"ema208",
"ema24",
"ema26",
"ema5",
"ema52",
"ema7",
"high",
"low",
"macd",
"macdhist",
"macdsignal",
"open",
"rsi",
"timestamp",
"volume",
"volume_ratio"
]
}
}
+188
View File
@@ -0,0 +1,188 @@
"""Step 44:1m 上「浅色信号(未过双过滤)到底能不能做」。
用户观察:图上很多浅色 b4/s4 的入场价看着也很有优势。这在 1m 上必须实测——
入场点「看着漂亮」和「扣完成本还剩正收益」之间隔着很大距离,而 1m 的固定成本
相对 ATR 特别重,正是最容易把视觉上的优势吃干净的级别。
step40 做过同类消融,但只覆盖 15m/30m,且用旧口径(5bp 平摊成本、SL/TP/超时
1.5/3.0/48)。1m 的成本压力与之完全不是一个量级,结论不能外推。
本步按当前最优 1m 口径复核:
出场 SL 2.0 / 3 ATR 减半 / runner 目标 8 ATR / runner 止损留在原位 / 48 根超时
成本 taker 2bp、maker 0.8bp,滑点只加在 taker 腿(见 lib/exit_model
门控 ATR >= 8bp
把信号按两个过滤标志切成四桶,看浅色那三桶扣费后是正是负。
"""
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)
LTF, HTF = "1m", "5m"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
GATE_BP = 8.0
def collect(sym: str, rows: int) -> 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.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", LTF, rows)
if df is None or len(df) < 50_000:
return None
chan = TF_DF(df, 1, LTF)
cdf = chan.dataframe
zones = add_zone_ladder(build_htf_zones(cdf, LTF, chan=chan).reset_index(drop=True))
if zones.empty:
return None
sig = find_fast_bsp3(cdf, zones)
if sig.empty:
return None
df_h = fetch_ohlcv(f"{sym}/USDT:USDT", HTF, 10 ** 9)
chan_h = TF_DF(df_h, 1, HTF)
tl = htf_fx_timeline(chan_h, chan_h.dataframe)
sig = attach_htf_agree(sig, cdf, tl)
sig = attach_zone_ladder(sig, zones)
res = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB],
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
need = [f"{cfg}_g", f"{cfg}_r", f"{cfg}_c", f"{cfg}_b"]
if any(c not in res.columns for c in need):
return None
idx = sig["entry_idx"].to_numpy().astype(int)
atr = cdf["atr"].to_numpy(float)[idx]
close = cdf["close"].to_numpy(float)[idx]
out = res[need].copy()
out.columns = ["g", "r", "c", "b"]
out["sym"] = sym
out["atr_pct"] = atr / close
out["htf_agree"] = sig["htf_agree"].to_numpy()
out["ladder_ok"] = sig["ladder_ok"].to_numpy()
out["date"] = cdf["date"].to_numpy()[idx]
return out
except Exception as e:
print(f" {sym} 失败: {e!r}", flush=True)
return None
def describe(g: pd.DataFrame, label: str) -> dict:
from lib.exit_model import FEE_MAKER, FEE_TAKER, TP, fee_of, taker_notional
if len(g) < 40:
return {"口径": label, "笔数": len(g), "备注": "样本不足"}
gross = g["g"].to_numpy()
reason, scaled = g["r"].to_numpy(), g["c"].to_numpy()
fee = fee_of(reason, scaled)
net = gross - fee # 未扣滑点:留出的就是滑点余量
R = net / (SL * g["atr_pct"].to_numpy())
gR = gross / (SL * g["atr_pct"].to_numpy())
w, o = net[net > 0], -net[net <= 0].sum()
q = np.quantile(net[net > 0], 0.90) if (net > 0).any() else 0.0
t10 = net[(net > 0) & (net <= q)].sum()
tn = taker_notional(reason, scaled)
return {
"口径": label, "笔数": len(g),
"胜率": f"{(net > 0).mean() * 100:.1f}%",
"毛R": round(gR.mean(), 3),
"净均R": round(R.mean(), 3),
"R夏普": round(R.mean() / R.std(ddof=1), 3),
"PF": round(w.sum() / o, 2) if o > 0 else np.inf,
"剔10%PF": round(t10 / o, 2) if o > 0 else np.inf,
"滑点余量bp": round(net.mean() / tn.mean() * 1e4, 2),
}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,XRP,DOGE,LINK,ADA,LTC")
ap.add_argument("--rows", type=int, default=300_000)
ap.add_argument("--workers", type=int, default=4)
args = ap.parse_args()
syms = [s.strip() for s in args.symbols.split(",")]
print(f"[1m 过滤器消融] {len(syms)}× {args.rows} 根,"
f"出场 SL{SL}/减半{SCALE_AT}/runner{RUNNER}/rstop{RSTOP}/{MAXB}\n", flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futs = {ex.submit(collect, s, args.rows): s for s in syms}
for i, f in enumerate(as_completed(futs), 1):
r = f.result()
if r is None:
print(f" [{i}/{len(syms)}] {futs[f]} 跳过", flush=True)
continue
parts.append(r)
print(f" [{i}/{len(syms)}] {futs[f]} 信号 {len(r)}", flush=True)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
d["a_bp"] = d["atr_pct"] * 1e4
d.to_feather(HERE / "out" / "step44_ablation_1m.feather")
for gate_lab, dd in (("未加门控", d), (f"ATR >= {GATE_BP:g}bp", d[d.a_bp >= GATE_BP])):
print("\n" + "=" * 118)
print(f"########## {gate_lab}(共 {len(dd)} 笔)##########")
dark = (dd["htf_agree"] == 1.0) & dd["ladder_ok"]
rows = [
describe(dd, "全部信号"),
describe(dd[dark], "深色:同向 ∧ 阶梯"),
describe(dd[~dark], "浅色:任一不过"),
describe(dd[(dd["htf_agree"] == 1.0) & ~dd["ladder_ok"]], " 只过同向"),
describe(dd[(dd["htf_agree"] != 1.0) & dd["ladder_ok"]], " 只过阶梯"),
describe(dd[(dd["htf_agree"] != 1.0) & ~dd["ladder_ok"]], " 两个都不过"),
]
print(pd.DataFrame([r for r in rows if r]).to_string(index=False))
print("\n########## 逐币:浅色是正是负(ATR 门控后)##########")
dd = d[d.a_bp >= GATE_BP]
rows = []
for s, g in dd.groupby("sym"):
dark = (g["htf_agree"] == 1.0) & g["ladder_ok"]
a, b = describe(g[dark], ""), describe(g[~dark], "")
rows.append({"": s,
"深色笔数": a.get("笔数"), "深色净均R": a.get("净均R"),
"深色余量bp": a.get("滑点余量bp"),
"浅色笔数": b.get("笔数"), "浅色净均R": b.get("净均R"),
"浅色余量bp": b.get("滑点余量bp")})
print(pd.DataFrame(rows).to_string(index=False))
print("\n注:余量为「扣手续费后、可用于吸收滑点的 bp」。"
"低于实测滑点即为亏损,不是「小赚」。")
if __name__ == "__main__":
main()
+262
View File
@@ -0,0 +1,262 @@
"""Step 43:重画率——实时看到过的信号,有多少后来消失了。
step39 也报过假阳性但那个测法是无效的它从全序列随机抽非信号点
看时点重建会不会凭空冒信号1m 上信号密度约 474 1 180 个随机点里
本来就只期望撞上 0.38 测出 0% 几乎不含信息量
重画不发生在随机点上只发生在差一点就成型的结构附近所以要
**逐根**做时点重建当根确实出现了信号的位置全收集起来
再看它们在全量视角里还在不在
实时信号 窗口只喂到第 T 重建后信号恰好落在第 T 实盘会下单的那些
重画 该信号在全量重建里不存在图上后来消失但实盘已经开了仓
漏看 全量有实时当根没有step39 已验证 ~0这里顺带复核
只有**深色信号**h1_agree ladder_ok才会真下单所以分层报告
浅色重画无所谓深色重画才影响实盘
"""
from __future__ import annotations
import argparse
import os
import sys
import time
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", 320)
LTF, HTF = "1m", "5m"
WINDOW = 2000 # step39 证明 1m 在 2000 根就饱和,实盘也用这个
HTF_WINDOW = 800 # 5m 侧窗口,同 step39
MAX_ROWS = 1_200_000
def _load(sym: str):
from chanlun import TF_DF
from lib.data import fetch_ohlcv
from lib.fx_signal import extract_fx_signals, signals_to_frame
from lib.nested_bsp import htf_fx_timeline
pair = f"{sym}/USDT:USDT"
df_l = fetch_ohlcv(pair, LTF, MAX_ROWS)
df_h = fetch_ohlcv(pair, HTF, 10 ** 9)
chan_l = TF_DF(df_l, 1, LTF)
cdf = chan_l.dataframe
chan_h = TF_DF(df_h, 1, HTF)
hdf = chan_h.dataframe
tl = htf_fx_timeline(signals_to_frame(extract_fx_signals(chan_h, hdf)), hdf)
return chan_l, cdf, hdf, tl
def _full_signals(chan_l, cdf, tl):
"""全量视角的信号集(带两个过滤器)。"""
from chanlun.analysis.fast_bsp import attach_zone_ladder
from lib.fast_bsp3 import find_fast_bsp3
from lib.nested_bsp import attach_htf_context
from lib.nested_level import build_htf_zones
zones = build_htf_zones(cdf, LTF, chan=chan_l).reset_index(drop=True)
if zones.empty:
return pd.DataFrame()
sig = find_fast_bsp3(cdf, zones)
if sig.empty:
return pd.DataFrame()
sig = attach_htf_context(sig, cdf, tl, "h1")
sig = attach_zone_ladder(sig, zones)
sig["ts"] = cdf["timestamp"].to_numpy()[sig["entry_idx"].astype(int)]
return sig
def scan_chunk(task: tuple) -> dict:
"""逐根时点重建,只记录信号恰好落在当根的位置。"""
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 attach_zone_ladder
from lib.fast_bsp3 import find_fast_bsp3
from lib.fx_signal import extract_fx_signals, signals_to_frame
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
from lib.nested_level import build_htf_zones
sym, lo, hi = task
try:
chan_l, cdf, hdf, _tl_full = _load(sym)
ltf_ts = cdf["timestamp"].to_numpy()
htf_ts = hdf["timestamp"].to_numpy()
hi = min(hi, len(cdf))
lo = max(lo, WINDOW)
hits, t0 = [], time.perf_counter()
for T in range(lo, hi):
sl = cdf.iloc[T - WINDOW + 1: T + 1].reset_index(drop=True)
z = build_htf_zones(sl, LTF)
if z.empty:
continue
z = z.reset_index(drop=True)
sig = find_fast_bsp3(sl, z)
if sig.empty:
continue
last = len(sl) - 1
row = sig[sig["entry_idx"].astype(int) == last]
if row.empty:
continue
# 只在真的出信号时才算过滤器——信号稀疏,这部分开销可忽略
h_end = int(np.searchsorted(htf_ts, ltf_ts[T], side="right"))
agree = np.nan
if h_end >= HTF_WINDOW:
hsl = hdf.iloc[h_end - HTF_WINDOW: h_end].reset_index(drop=True)
ch = TF_DF(hsl, 1, HTF)
tl_p = htf_fx_timeline(
signals_to_frame(extract_fx_signals(ch, ch.dataframe)), ch.dataframe)
got = attach_htf_context(row.copy(), sl, tl_p, "h1")
agree = float(got["h1_agree"].iloc[0] == 1)
lad = attach_zone_ladder(row.copy(), z)
hits.append({
"sym": sym, "T": int(T), "ts": int(ltf_ts[T]),
"direction": int(row["direction"].iloc[0]),
"h1_agree": agree,
"ladder_ok": bool(lad["ladder_ok"].iloc[0]),
})
return {"sym": sym, "lo": lo, "hi": hi, "n_bars": hi - lo,
"hits": pd.DataFrame(hits), "secs": time.perf_counter() - t0}
except Exception as e:
return {"sym": sym, "error": repr(e)[:300]}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,DOGE,XRP,LINK")
ap.add_argument("--bars", type=int, default=30000, help="每币逐根扫描的根数")
ap.add_argument("--workers", type=int, default=10)
ap.add_argument("--chunk", type=int, default=2500)
args = ap.parse_args()
syms = [s.strip() for s in args.symbols.split(",")]
out_dir = HERE / "out"
out_dir.mkdir(exist_ok=True)
# 先拿各币全量信号做基准,同时确定扫描区间
print(f"[重画审计] {len(syms)}× 每币 {args.bars} 根逐根重建,"
f"窗口 {WINDOW}\n", flush=True)
full_map, tasks = {}, []
for s in syms:
try:
chan_l, cdf, hdf, tl = _load(s)
full = _full_signals(chan_l, cdf, tl)
full_map[s] = full
hi = len(cdf) - 1
lo = max(WINDOW, hi - args.bars)
for a in range(lo, hi, args.chunk):
tasks.append((s, a, min(a + args.chunk, hi)))
print(f" {s}: 全量 {len(cdf)} 根,全量信号 {len(full)}"
f"扫描 [{lo}, {hi})", flush=True)
except Exception as e:
print(f" {s}: 载入失败 {e!r}", flush=True)
est = sum(t[2] - t[1] for t in tasks) * 0.202 / args.workers
print(f"\n{len(tasks)} 个分块,预计 {est / 60:.0f} 分钟\n", flush=True)
res, done = [], 0
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futs = {ex.submit(scan_chunk, t): t for t in tasks}
for f in as_completed(futs):
r = f.result()
done += 1
if "error" in r:
print(f" [{done}/{len(tasks)}] {r['sym']} 出错 {r['error']}", flush=True)
continue
res.append(r)
print(f" [{done}/{len(tasks)}] {r['sym']} [{r['lo']},{r['hi']}) "
f"实时信号 {len(r['hits'])} 个,{r['secs']:.0f}s", flush=True)
if not res:
print("无结果")
return
live = pd.concat([r["hits"] for r in res if len(r["hits"])], ignore_index=True)
live.to_feather(out_dir / "step43_live_signals.feather")
n_bars = sum(r["n_bars"] for r in res)
# 对齐:实时信号的时间戳是否出现在全量信号集里
rows = []
for s, g in live.groupby("sym"):
full = full_map.get(s)
fts = set(full["ts"].astype(int).tolist()) if full is not None and len(full) else set()
g = g.copy()
g["survived"] = g["ts"].isin(fts)
rows.append(g)
live = pd.concat(rows, ignore_index=True)
live["dark"] = (live["h1_agree"] == 1.0) & live["ladder_ok"]
print("\n" + "=" * 100)
print(f"########## 1. 总体(扫描 {n_bars} 根)##########")
n, sv = len(live), int(live["survived"].sum())
print(f" 实时出现过的信号 {n} 个,全量视角仍在 {sv} 个,"
f"重画 {n - sv} 个 = {(n - sv) / max(n, 1) * 100:.2f}%")
print("\n########## 2. 按过滤器分层(只有深色会真下单)##########")
rows = []
for lab, m in (("深色(双过滤通过)", live["dark"]),
("浅色(未通过)", ~live["dark"])):
gg = live[m]
if not len(gg):
continue
k = int((~gg["survived"]).sum())
# Wilson 95% 上界,样本小的时候点估计没意义
from math import sqrt
nn, p = len(gg), k / len(gg)
z = 1.96
hi_b = (p + z * z / (2 * nn) + z * sqrt(p * (1 - p) / nn + z * z / (4 * nn * nn))) / (1 + z * z / nn)
rows.append({"分层": lab, "实时信号": nn, "重画": k,
"重画率": f"{p * 100:.2f}%", "95%上界": f"{hi_b * 100:.2f}%"})
print(pd.DataFrame(rows).to_string(index=False))
print("\n########## 3. 分币种 ##########")
rows = []
for s, g in live.groupby("sym"):
d = g[g["dark"]]
rows.append({"": s, "实时信号": len(g), "其中深色": len(d),
"深色重画": int((~d["survived"]).sum()) if len(d) else 0,
"全部重画": int((~g["survived"]).sum())})
print(pd.DataFrame(rows).to_string(index=False))
print("\n########## 4. 漏看(全量有、实时当根没有)复核 ##########")
for s, g in live.groupby("sym"):
full = full_map.get(s)
if full is None or not len(full):
continue
lo = min(r["lo"] for r in res if r["sym"] == s)
hi = max(r["hi"] for r in res if r["sym"] == s)
inrange = full[(full["entry_idx"] >= lo) & (full["entry_idx"] < hi)]
seen = set(g["ts"].astype(int).tolist())
miss = int((~inrange["ts"].astype(int).isin(seen)).sum())
print(f" {s}: 扫描区间内全量信号 {len(inrange)},实时当根未出现 {miss}"
f"{miss / max(len(inrange), 1) * 100:.1f}%")
print("\n########## 结论 ##########")
d = live[live["dark"]]
if len(d):
k = int((~d["survived"]).sum())
print(f" 深色信号 {len(d)} 个,重画 {k} 个。")
print(" 重画的仓位是真实成交的,但出场(止损/止盈/超时)不依赖信号是否还在图上,")
print(" 所以不会卡仓;影响仅限于「实盘比回测多开的这部分,质量不在回测统计里」。")
if __name__ == "__main__":
main()
+200
View File
@@ -0,0 +1,200 @@
"""Step 46:缠论引擎的逐位对拍基线。
重构引擎前先固化一份指纹改完再对一次没有它任何"精简"都无法证明
没有改变行为 HANDOFF 里所有回测数字都绑定当前实现**行为变化是静默的**
不报错不崩溃只是信号悄悄变了一批
指纹覆盖三条链路各自依赖的东西
结构 klu / klc / bi / bi_zs / seg 的数量与关键端点
信号 中枢表(zg/zd/available_ts) fast_bsp3 的全部输出列
数值 dataframe 上被下游真正消费的列逐位比较
用法
python step46_engine_parity.py --save # 改动前,存基线
python step46_engine_parity.py --check # 改动后,对比
"""
from __future__ import annotations
import argparse
import hashlib
import json
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))
pd.set_option("display.width", 300)
BASELINE = HERE / "out" / "step46_baseline.json"
# 覆盖面:多币多级别,1m 用较短窗口以免跑太久
CASES = [
("BTC/USDT:USDT", "1m", 20_000),
("BTC/USDT:USDT", "5m", 20_000),
("ETH/USDT:USDT", "5m", 20_000),
("SOL/USDT:USDT", "15m", 20_000),
("XRP/USDT:USDT", "30m", 20_000),
]
# 下游真正消费的列(见 §5.5 的列使用扫描)。精简若动到这些,必须体现在指纹里。
CONSUMED = ["open", "high", "low", "close", "volume", "atr",
"macd", "macdsignal", "macdhist",
"ema5", "ema13", "ema24", "ema26", "ema52", "ema104", "ema156", "ema208", "ema7",
"rsi", "volume_ratio",
"bb2633upper", "bb2633lower", "bb2633middle",
"bbp30", "bbp120", "bbp365"]
def _h(arr) -> str:
a = np.asarray(arr, dtype=np.float64)
a = np.nan_to_num(a, nan=-9.87654321e30, posinf=1e300, neginf=-1e300)
return hashlib.sha256(a.tobytes()).hexdigest()[:16]
def fingerprint(pair: str, tf: str, rows: int) -> dict:
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
add_zone_ladder, build_htf_zones, find_fast_bsp3,
)
from lib.data import fetch_ohlcv
df = fetch_ohlcv(pair, tf, rows)
chan = TF_DF(df, 1, tf)
cdf = chan.dataframe
fp: dict = {"n_rows": int(len(cdf))}
# --- 结构 ---
fp["n_klu"] = len(getattr(chan, "klu_list", []) or [])
fp["n_klc"] = len(getattr(chan, "klc_list", []) or [])
fp["n_bi"] = len(getattr(chan, "bi_list", []) or [])
fp["n_seg"] = len(getattr(chan, "seg_list", []) or [])
fp["n_bi_zs"] = len(getattr(chan, "bi_zs_list", []) or [])
fp["n_zs"] = len(getattr(chan, "zs_list", []) or [])
fp["n_bsp"] = len(getattr(chan, "bsp_list", []) or [])
# KLC 端点(包含关系的结果,最容易被指标改动影响)
klc = getattr(chan, "klc_list", []) or []
fp["klc_high"] = _h([k.high for k in klc])
fp["klc_low"] = _h([k.low for k in klc])
fp["klc_fx"] = _h([float(getattr(k.fx, "value", 0) or 0) for k in klc])
# 笔端点
bi = getattr(chan, "bi_list", []) or []
fp["bi_start"] = _h([float(getattr(b, "start_price", 0) or 0) for b in bi])
fp["bi_end"] = _h([float(getattr(b, "end_price", 0) or 0) for b in bi])
fp["bi_sure"] = _h([1.0 if getattr(b, "is_sure", False) else 0.0 for b in bi])
# --- 信号 ---
zones = build_htf_zones(cdf, tf, chan=chan)
fp["n_zones"] = int(len(zones))
if len(zones):
zl = add_zone_ladder(zones.reset_index(drop=True))
fp["zone_zg"] = _h(zl["zg"])
fp["zone_zd"] = _h(zl["zd"])
fp["zone_avail"] = _h(zl["available_ts"])
fp["zone_ladder"] = _h(zl["z_above"].astype(float) * 2 + zl["z_below"].astype(float))
sig = find_fast_bsp3(cdf, zl)
fp["n_sig"] = int(len(sig))
for c in ("entry_idx", "direction", "bo_idx", "pb_idx", "lag", "depth", "zone_i"):
if c in sig.columns:
fp[f"sig_{c}"] = _h(sig[c])
else:
fp["n_sig"] = 0
# --- 数值列(只对下游消费的列逐位比较)---
for c in CONSUMED:
fp[f"col_{c}"] = _h(cdf[c]) if c in cdf.columns else "MISSING"
fp["_all_columns"] = sorted(map(str, cdf.columns))
return fp
def collect() -> dict:
out = {}
for pair, tf, rows in CASES:
key = f"{pair.split('/')[0]}_{tf}"
print(f" 计算 {key} ...", flush=True)
try:
out[key] = fingerprint(pair, tf, rows)
except Exception as e:
out[key] = {"error": repr(e)[:200]}
print(f" 失败: {e!r}", flush=True)
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--save", action="store_true", help="存为基线")
ap.add_argument("--check", action="store_true", help="与基线对比")
ap.add_argument("--rows", type=int, default=0, help="覆盖各用例根数(验证大样本时用)")
ap.add_argument("--out", default="", help="基线文件名,便于大小样本各存一份")
args = ap.parse_args()
global BASELINE, CASES
if args.out:
BASELINE = HERE / "out" / args.out
if args.rows:
CASES = [(p, tf, args.rows) for p, tf, _ in CASES]
if not (args.save or args.check):
ap.error("需要 --save 或 --check")
BASELINE.parent.mkdir(exist_ok=True)
print(f"[引擎对拍] {len(CASES)} 个用例\n")
cur = collect()
if args.save:
BASELINE.write_text(json.dumps(cur, ensure_ascii=False, indent=1))
print(f"\n基线已存:{BASELINE}")
for k, v in cur.items():
if "error" in v:
continue
print(f" {k}: klu {v['n_klu']} klc {v['n_klc']} bi {v['n_bi']} "
f"中枢 {v['n_zones']} 信号 {v['n_sig']} 列数 {len(v['_all_columns'])}")
return
if not BASELINE.exists():
print(f"基线不存在:{BASELINE},先跑 --save")
return
old = json.loads(BASELINE.read_text())
print("\n" + "=" * 90)
bad = 0
for key in sorted(set(old) | set(cur)):
o, n = old.get(key), cur.get(key)
if o is None or n is None:
print(f"{key}: 用例缺失")
bad += 1
continue
# 列集合单独看:删列是预期内的,不算行为变化
o_cols, n_cols = set(o.get("_all_columns", [])), set(n.get("_all_columns", []))
diffs = [k for k in o if k != "_all_columns" and o.get(k) != n.get(k)]
dropped, added = sorted(o_cols - n_cols), sorted(n_cols - o_cols)
# 被删列在指纹里会变成 MISSING,若该列本就不被消费则无害
harmful = [d for d in diffs if not (d.startswith("col_") and n.get(d) == "MISSING"
and d[4:] not in CONSUMED)]
if not harmful:
print(f"{key}: 行为一致"
+ (f"(删列 {len(dropped)} 个)" if dropped else ""))
else:
bad += 1
print(f"{key}: {len(harmful)} 项不一致")
for d in harmful[:12]:
print(f" {d}: {o.get(d)}{n.get(d)}")
if dropped:
print(f" 删掉的列: {dropped}")
if added:
print(f" 新增的列: {added}")
print("\n" + ("✅ 全部用例行为一致,可以放心继续" if not bad
else f"{bad} 个用例有行为变化——**回测数字已失效,不要继续**"))
if __name__ == "__main__":
main()
+195
View File
@@ -0,0 +1,195 @@
"""Step 47:中枢可用时刻取 bis[-1] 还是 bis[2] —— 按收益判,不按重画率判。
§5.41 发现 available_ts 中枢最后一笔是右边缘重画的根因改取第三笔
中枢成立即固定能把重画率从 6.5% 压到 1.2%但那只测了稳定性
小样本预检60k × 5 个币/周期显示这不是一次稳定性修补
信号数 +17% ~ +44%而两组的**重合度只有约 30%**
bis[2] 丢掉了原信号的多数又换进来一批新的
换句话说它是另一个策略不是同一个策略的低延迟版所以判据必须是扣费后的
R / PF / 滑点预算重画率只能作为次要参考
口径与 step44 一致当前 1m 最优
出场 SL 2.0 / 3 ATR 减半 / runner 目标 8 ATR / runner 止损留原位 / 48 根超时
成本 taker 2bpmaker 0.8bp滑点只加在 taker
门控 ATR >= 8bp
过滤 深色同向 阶梯实盘只做这一档 §3.38
两组共用同一个 TF_DF只切 available_ts 的取法确保差异只来自这一处
"""
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)
LTF, HTF = "1m", "5m"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
GATE_BP = 8.0
VARIANTS = (("bis[-1] 现行", -1), ("bis[2] 中枢成立", 2))
def collect(sym: str, rows: int) -> 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.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", LTF, rows)
if df is None or len(df) < 50_000:
return None
# lean:只需要笔/中枢/信号,跳过线段与 MACD 状态机(见 §5.6,已验证等价)
chan = TF_DF(df, 1, LTF, lean=True)
cdf = chan.dataframe
df_h = fetch_ohlcv(f"{sym}/USDT:USDT", HTF, 10 ** 9)
chan_h = TF_DF(df_h, 1, HTF, lean=True)
tl = htf_fx_timeline(chan_h, chan_h.dataframe)
idx_all, parts = None, []
for label, avail_bi in VARIANTS:
zones = build_htf_zones(cdf, LTF, chan=chan, avail_bi=avail_bi)
if zones.empty:
continue
zl = add_zone_ladder(zones.reset_index(drop=True))
sig = find_fast_bsp3(cdf, zl)
if sig.empty:
continue
sig = attach_zone_ladder(attach_htf_agree(sig, cdf, tl), zl)
res = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB],
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
need = [f"{cfg}_g", f"{cfg}_r", f"{cfg}_c", f"{cfg}_b"]
if any(c not in res.columns for c in need):
continue
idx = sig["entry_idx"].to_numpy().astype(int)
atr = cdf["atr"].to_numpy(float)[idx]
close = cdf["close"].to_numpy(float)[idx]
out = res[need].copy()
out.columns = ["g", "r", "c", "b"]
out["sym"] = sym
out["variant"] = label
out["atr_pct"] = atr / close
out["lag"] = sig["lag"].to_numpy()
out["entry_idx"] = idx
out["htf_agree"] = sig["htf_agree"].to_numpy()
out["ladder_ok"] = sig["ladder_ok"].to_numpy()
parts.append(out)
return pd.concat(parts, ignore_index=True) if parts else None
except Exception as e:
print(f" {sym} 失败: {e!r}", flush=True)
return None
def describe(g: pd.DataFrame, label: str) -> dict:
from lib.exit_model import fee_of, taker_notional
if len(g) < 40:
return {"口径": label, "笔数": len(g), "备注": "样本不足"}
gross = g["g"].to_numpy()
reason, scaled = g["r"].to_numpy(), g["c"].to_numpy()
net = gross - fee_of(reason, scaled) # 未扣滑点:剩下的就是滑点余量
denom = SL * g["atr_pct"].to_numpy()
R, gR = net / denom, gross / denom
w, o = net[net > 0], -net[net <= 0].sum()
q = np.quantile(net[net > 0], 0.90) if (net > 0).any() else 0.0
t10 = net[(net > 0) & (net <= q)].sum()
tn = taker_notional(reason, scaled)
return {
"口径": label, "笔数": len(g),
"滞后": round(float(g["lag"].mean()), 2),
"胜率": f"{(net > 0).mean() * 100:.1f}%",
"毛R": round(gR.mean(), 3),
"净均R": round(R.mean(), 3),
"R夏普": round(R.mean() / R.std(ddof=1), 3),
"PF": round(w.sum() / o, 2) if o > 0 else np.inf,
"剔10%PF": round(t10 / o, 2) if o > 0 else np.inf,
"滑点余量bp": round(net.mean() / tn.mean() * 1e4, 2),
}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,XRP,DOGE,LINK,ADA,LTC")
ap.add_argument("--rows", type=int, default=300_000)
ap.add_argument("--workers", type=int, default=3)
args = ap.parse_args()
syms = [s.strip() for s in args.symbols.split(",")]
print(f"[available_ts A/B] {len(syms)}× {args.rows}{LTF}\n"
f"出场 SL{SL}/减半{SCALE_AT}/runner{RUNNER}/rstop{RSTOP}/{MAXB}根,"
f"ATR 门控 {GATE_BP:g}bp\n", flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futs = {ex.submit(collect, s, args.rows): s for s in syms}
for i, f in enumerate(as_completed(futs), 1):
r = f.result()
if r is None:
print(f" [{i}/{len(syms)}] {futs[f]} 跳过", flush=True)
continue
parts.append(r)
n = r.groupby("variant").size().to_dict()
print(f" [{i}/{len(syms)}] {futs[f]} {n}", flush=True)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
d["a_bp"] = d["atr_pct"] * 1e4
d.to_feather(HERE / "out" / "step47_avail_bi.feather")
dark = (d["htf_agree"] == 1.0) & d["ladder_ok"]
for lab, dd in (("全部信号", d), ("深色(实盘口径)", d[dark])):
for gate_lab, ddd in (("未门控", dd), (f"ATR>={GATE_BP:g}bp", dd[dd.a_bp >= GATE_BP])):
print("\n" + "=" * 118)
print(f"########## {lab} / {gate_lab} ##########")
print(pd.DataFrame([describe(ddd[ddd.variant == v], v)
for v, _ in [(a, b) for a, b in VARIANTS]]).to_string(index=False))
print("\n########## 逐币(深色 + 门控)##########")
dd = d[dark & (d.a_bp >= GATE_BP)]
rows = []
for s, g in dd.groupby("sym"):
r = {"": s}
for v, _ in VARIANTS:
x = describe(g[g.variant == v], v)
tag = "现行" if "-1" in v else ""
r[f"{tag}_笔数"] = x.get("笔数")
r[f"{tag}_净均R"] = x.get("净均R")
r[f"{tag}_余量bp"] = x.get("滑点余量bp")
rows.append(r)
print(pd.DataFrame(rows).to_string(index=False))
print("\n判据:净均R 与滑点余量bp 同时不劣于现行,才值得换。"
"\n信号数变多本身不是好处——重合度只有约 30%,换的是另一批交易。")
if __name__ == "__main__":
main()