"""第四类买卖点(B4/S4)接入 TF_DF。 判定逻辑全在 chanlun/analysis/fast_bsp.py,这里只负责把引擎的中枢/K线喂进去, 再把结果包成 ChanFastBSP。 刻意不在 init_TF_DF 里默认计算:现有构造路径的开销保持不变,由调用方按需触发。 """ from __future__ import annotations import re import pandas as pd from chanlun.analysis.fast_bsp import ( add_zone_ladder, attach_htf_agree, attach_zone_ladder, ensure_timestamp, find_fast_bsp3, htf_fx_timeline, zones_from_zs_list, ) from chanlun.core.ChanEnum import Chan_BSP_DIR from chanlun.core.ChanFastBSP import ChanFastBSP # 区间套配对:小级别出中枢与买卖点,大级别只出分型定方向。 # 取值来自 research/HANDOFF.md §1.5,是回测里实际用过的组合。 FAST_BSP_HTF_PAIR = { '1m': '5m', '5m': '30m', '15m': '1h', '30m': '2h', } # 未列入配对表的周期回落到这个倍数 FAST_BSP_HTF_FALLBACK_RATIO = 4 _TF_UNIT_MINUTES = {'m': 1, 'h': 60, 'd': 1440, 'w': 10080} def timeframe_minutes(tf: str) -> int | None: """'30m' -> 30,'2h' -> 120。无法解析时返回 None。""" if not tf: return None m = re.fullmatch(r'(\d+)\s*([mhdw])', str(tf).strip().lower()) if not m: return None return int(m.group(1)) * _TF_UNIT_MINUTES[m.group(2)] def resolve_htf(tf: str) -> tuple[str, int] | None: """给小级别找配套的大级别,返回 (标签, 分钟数)。""" minutes = timeframe_minutes(tf) if minutes is None: return None paired = FAST_BSP_HTF_PAIR.get(str(tf).strip().lower()) if paired: return paired, timeframe_minutes(paired) return f'{minutes * FAST_BSP_HTF_FALLBACK_RATIO}m', minutes * FAST_BSP_HTF_FALLBACK_RATIO class FastBspBuilderMixin: def build_fast_bsp_htf(self, df, timeframe=None): """对同一份 df 重采样得到大级别,不额外拉数据。 大级别只用来取分型方向,样本太少就没有过滤意义,故重采样后不足 60 根时放弃。 """ tf = timeframe or getattr(self, 'timeframe', None) htf = resolve_htf(tf) ltf_minutes = timeframe_minutes(tf) if htf is None or not ltf_minutes: return None label, minutes = htf if not minutes or len(df) * ltf_minutes < minutes * 60: return None try: from chanlun.pipeline.timeframe import TF_DF return TF_DF(df, minutes, label) except Exception: return None def cal_fast_bsp(self, df=None, bi_zs_list=None, htf_chan=None, with_htf=True, timeframe=None, **kw): """算第四类买卖点,返回 ChanFastBSP 列表。 bi_zs_list 传入已算好的 pure 笔中枢可免去重复计算。 with_htf=False 时跳过大级别构建,只留 ladder_ok 这一个过滤标志。 kw 透传给 find_fast_bsp3(scan / pullback_win / tol / require_touch 等)。 """ src = df if df is not None else getattr(self, 'dataframe', None) if src is None or len(src) == 0: self.fast_bsp_list = [] return self.fast_bsp_list src = ensure_timestamp(src) if bi_zs_list is None: bi_zs_list = getattr(self, 'bi_zs_list', None) if not bi_zs_list: bi_zs_list = self.cal_bi_zs_list_pure(self.cal_bi_list(self.get_klc_list(self.cal_kl_data(src)))) zones = zones_from_zs_list(bi_zs_list, src) if zones.empty: self.fast_bsp_list = [] return self.fast_bsp_list zones = add_zone_ladder(zones) sig = find_fast_bsp3(src, zones, **kw) if sig.empty: self.fast_bsp_list = [] return self.fast_bsp_list sig = attach_zone_ladder(sig, zones) if with_htf: if htf_chan is None: htf_chan = self.build_fast_bsp_htf(src, timeframe) sig = attach_htf_agree(sig, src, htf_fx_timeline(htf_chan) if htf_chan is not None else pd.DataFrame()) else: sig['htf_dir'] = None sig['htf_agree'] = None times = src['date'].dt.strftime('%Y-%m-%d %H:%M:%S').to_numpy() close = src['close'].to_numpy(dtype=float) out = [] for r in sig.itertuples(index=False): entry_idx = int(r.entry_idx) agree = getattr(r, 'htf_agree', None) htf_dir = getattr(r, 'htf_dir', None) out.append(ChanFastBSP( time=times[entry_idx], price=close[entry_idx], ddir=Chan_BSP_DIR.BUY if r.direction == 1 else Chan_BSP_DIR.SELL, entry_idx=entry_idx, bo_time=times[int(r.bo_idx)], pb_time=times[int(r.pb_idx)] if r.pb_idx == r.pb_idx else None, lag=r.lag, depth=r.depth, zg=r.zg, zd=r.zd, occ=r.occ, htf_dir=None if htf_dir is None or htf_dir != htf_dir else int(htf_dir), htf_agree=None if agree is None or agree != agree else bool(agree), ladder_ok=bool(r.ladder_ok), )) self.fast_bsp_list = out return out