From 9b72173285c8f1293d0bc59d878302118a744a06 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 28 Aug 2026 00:05:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AC=AC=E5=9B=9B=E7=B1=BB=E4=B9=B0?= =?UTF-8?q?=E5=8D=96=E7=82=B9=EF=BC=88B4/S4=EF=BC=89=E8=9E=8D=E5=85=A5?= =?UTF-8?q?=E7=BC=A0=E8=AE=BA=E5=BC=95=E6=93=8E=E4=B8=8E=20web=20=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 研究侧的 fast_bsp3 一直只活在 research/lib/ 里,web 端看不到,回测与目视 两条线对不上。这次把它搬进引擎,作为独立的第四类买卖点。 之所以单独立类而不是当作 B3/S3 的低滞后版:step30/31 显示引擎原生的 B3/S3 统计上呈逆势、显著亏损(胜率 27.4%、PF 0.66、t −18.76),而同一组 过滤器把 B4 从 PF 1.59 提到 2.26 却对它无效(0.66→0.71)。两者选的是 不同的交易群体,不是同一信号的早晚两版。 - chanlun/analysis/fast_bsp.py 原样搬入 find_fast_bsp3 与 build_htf_zones, 另加 add_zone_ladder / htf_fx_timeline / attach_htf_agree - research/lib/ 两个模块改为转发,所有 step 脚本导入不变,信号逐条比对一致 - 大级别上下文用 resample 从同一份 df 构建,不额外拉数据,因此与界面上选的 周期和时间范围无关 - 前端三个复选框 + 过滤模式下拉;未过滤的原始信号用浅色,避免与主口径混淆 Co-authored-by: Cursor --- chanlun/analysis/fast_bsp.py | 361 ++++++++++++++++++ chanlun/core/ChanEnum.py | 4 + chanlun/core/ChanFastBSP.py | 40 ++ chanlun/pipeline/builders/fast_bsp.py | 148 +++++++ chanlun/pipeline/orchestrator.py | 2 + chanlun/pipeline/timeframe.py | 4 +- research/lib/fast_bsp3.py | 171 +-------- research/lib/nested_level.py | 39 +- web/api/analyze.py | 6 +- web/services/runtime/__init__.py | 1 + web/services/runtime/analyze.py | 10 + web/services/runtime/serialize.py | 30 ++ web/static/js/app/chart_tv_overlays.js | 63 ++- web/static/js/app/ui.js | 5 + web/templates/index.html | 23 +- web/tests/fixtures/analyze_contract_keys.json | 1 + web/tests/test_analyze_contract.py | 3 +- 17 files changed, 702 insertions(+), 209 deletions(-) create mode 100644 chanlun/analysis/fast_bsp.py create mode 100644 chanlun/core/ChanFastBSP.py create mode 100644 chanlun/pipeline/builders/fast_bsp.py diff --git a/chanlun/analysis/fast_bsp.py b/chanlun/analysis/fast_bsp.py new file mode 100644 index 0000000..fd0e140 --- /dev/null +++ b/chanlun/analysis/fast_bsp.py @@ -0,0 +1,361 @@ +"""快速三类买卖点(引擎内称第四类,B4/S4)。 + +原本长在 `research/lib/fast_bsp3.py`,现移入引擎作为唯一实现,研究脚本改为转发导入, +这样回测口径与 web 图表永远一致。 + +命名说明:缠论原文里没有「第四类买卖点」,但 B4/S4 也不只是「B3/S3 提前几根」—— +两者的选样口径不同,统计性质符号相反,故单独立类。形态条件是实时可判的: + + 中枢已成 -> 收盘突破 zg -> 收盘未跌回中枢 -> 重新上行 + +最后一步发生的当根就能下单,滞后约 2 根,而引擎 B3/S3 要等 pullback_bi.sure_time, +滞后 9~10 根。但差别不止滞后: + + 判据 引擎用笔端点事后判(回拉笔低点 >= zg),本函数用收盘价实时判 + 方向 引擎由离开笔方向决定,本函数由收盘从哪一侧突破决定 + 口径 627 个中枢里引擎发 625 个信号(几乎不筛),本函数只认 212 个(34%); + 被拒的多数是「中枢确认时价格早已离开、此后再没回来」的历史区间 + +step30 同条件对拍(同一套 pure 笔中枢、同一组过滤器、同样的 1.5/3.0/48 出场): + + 原始 PF +大级别同向 +同向+阶梯 胜率 t值 + 引擎 B3/S3 0.66 0.66 0.71 27.4% -18.76 + 本函数 B4/S4 1.59 1.85 2.26 47.1% +10.09 + +同一组过滤器对 B4 有效、对 B3 无效,滞后差解释不了这一点(入场后移 1~4 根只是 +PF 3.18->2.53 的平滑衰减)。且 SL1.5/TP3.0 下随机入场胜率约 33%,引擎那 27.4% +低于随机——它选中的是一批系统性反向的样本,不是「晚了所以差」。 + +require_touch 控制是否强求回抽碰到中枢边界。step32/33 的实测表明强求反而更差: +这等于排除掉「突破后一去不回头」的强势段,而那正是缠论里最强的趋势形态。 +故默认 False(笔数 +21%、滞后 -1 根、PF 2.26→2.37)。 + +判据本身(收盘越过前一根极值)没有独立预测力:裸用 15 万笔样本 PF 0.95, +把中枢换成「近20根高点」这类伪阻力位后 PF 0.90。alpha 全部来自中枢结构, +判据只负责在这个已知价位上确认动能恢复。它也不能用于识别笔端点—— +入场前的回抽极值命中笔端点±2根的比例 19.3%,低于随机基准 22%。 + +全部判定只使用当根及之前的数据,无未来函数。 +""" +from __future__ import annotations + +import numpy as np +import pandas as pd + +from chanlun.core.ChanEnum import Chan_FX_TYPE + + +def timestamps_ms(src: pd.DataFrame) -> np.ndarray: + """取毫秒时间戳。研究侧的 df 自带 timestamp,web 侧的不一定,故按 date 回退。 + + 回退写法不能用 `date.astype("int64") // 10**6`:该值单位取决于列精度, + 对毫秒精度的列会把时间戳砸平。 + """ + if "timestamp" in src.columns: + return src["timestamp"].to_numpy() + d = pd.to_datetime(src["date"]) + if getattr(d.dt, "tz", None) is None: + d = d.dt.tz_localize("UTC") + return (d.dt.tz_convert("UTC").dt.tz_localize(None) + .astype("datetime64[ms]").astype("int64").to_numpy()) + + +def ensure_timestamp(df: pd.DataFrame) -> pd.DataFrame: + """保证 df 带 timestamp 列,缺失时补一份副本,不改动调用方的对象。""" + if "timestamp" in df.columns: + return df + out = df.copy() + out["timestamp"] = timestamps_ms(out) + return out + + +def build_htf_zones(df_htf: pd.DataFrame, tf: str, chan=None) -> pd.DataFrame: + """算 pure 笔中枢,返回带生效时间的区间表。 + + available_ts —— 该中枢最早可被使用的时间戳(其确认时刻)。 + 传入已构建好的 chan 可避免重复跑一遍 pipeline(大数据集上省一半时间)。 + """ + if chan is None: + from chanlun import TF_DF + + chan = TF_DF(df_htf, 1, tf) + zs_list = chan.cal_bi_zs_list_pure(chan.bi_list) + # 用引擎自己的 dataframe 对齐,避免调用方传入的 df 与引擎内部行数不一致 + src = chan.dataframe if getattr(chan, "dataframe", None) is not None else df_htf + return zones_from_zs_list(zs_list, src) + + +def zones_from_zs_list(zs_list, src: pd.DataFrame) -> 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))) + + 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 "") + avail = ts_of.get(sure_key) or ts_of.get(end_key) + if avail is None: + continue + start_key = str(bis[0].start_time) + rows.append({ + "zg": float(zs.zg), "zd": float(zs.zd), + "gg": float(getattr(zs, "gg", zs.zg)), "dd": float(getattr(zs, "dd", zs.zd)), + "start_ts": ts_of.get(start_key, avail), + "available_ts": int(avail), + }) + out = pd.DataFrame(rows) + return out.sort_values("available_ts").reset_index(drop=True) if not out.empty else out + + +def add_zone_ladder(zones: pd.DataFrame) -> pd.DataFrame: + """标注每个中枢相对前一个中枢是否同向推进(缠论「趋势 vs 盘整」)。 + + z_above / z_below 分别对应向上、向下推进。买信号要求 z_above、卖信号要求 z_below + 时,30m/2h 的 PF 从 2.72 升到 3.41。 + """ + out = zones.copy() + if out.empty: + out["z_above"], out["z_below"] = pd.Series(dtype=bool), pd.Series(dtype=bool) + return out + pg, pdn = out["zg"].shift(), out["zd"].shift() + out["z_above"] = (out["zd"] > pg).fillna(False) + out["z_below"] = (out["zg"] < pdn).fillna(False) + return out + + +def htf_fx_timeline(chan_htf, df_htf: pd.DataFrame | None = None) -> pd.DataFrame: + """把大级别分型压成一条按确认时间排序的时间线。 + + confirm_ts 是该分型最早可被使用的时刻。timestamp 是K线开盘时刻,而分型要等这根K线 + 收盘才算数,所以整体后移一个大级别周期;否则小级别会提前一整根大级别K线拿到信号。 + + 只取方向与确认时刻——同向过滤用不到背驰强度,省掉 MACD 面积计算。 + """ + src = chan_htf.dataframe if getattr(chan_htf, "dataframe", None) is not None else df_htf + if src is None or len(src) == 0: + return pd.DataFrame(columns=["confirm_ts", "fx_ts", "direction", "price"]) + ts = timestamps_ms(src) + idx_of = {t: i for i, t in enumerate(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"))} + period = int(np.median(np.diff(ts))) if len(ts) > 1 else 0 + + rows = [] + for klc in getattr(chan_htf, "klc_list", []): + if klc.fx not in (Chan_FX_TYPE.TOP, Chan_FX_TYPE.BOTTOM): + continue + if klc.next is None or klc.next.end_klu is None: + continue + e_key, c_key = str(klc.end_time), str(klc.next.end_klu.time) + if e_key not in idx_of or c_key not in idx_of: + continue + fx_idx, confirm_idx = idx_of[e_key], idx_of[c_key] + if confirm_idx <= fx_idx: + continue + d = 1 if klc.fx == Chan_FX_TYPE.BOTTOM else -1 + rows.append({ + "confirm_ts": int(ts[confirm_idx]) + period, + "fx_ts": int(ts[fx_idx]), + "direction": d, + "price": float(klc.low if d == 1 else klc.high), + }) + out = pd.DataFrame(rows) + if out.empty: + return pd.DataFrame(columns=["confirm_ts", "fx_ts", "direction", "price"]) + return out.sort_values("confirm_ts").reset_index(drop=True) + + +def attach_htf_agree(sig: pd.DataFrame, df_ltf: pd.DataFrame, tl: pd.DataFrame) -> pd.DataFrame: + """给每个小级别信号挂上「入场时刻之前最近的大级别分型」是否同向。 + + 产出 htf_dir(+1 底 / -1 顶)与 htf_agree(1 同向 / 0 反向 / NaN 无可用分型)。 + """ + out = sig.copy() + if sig.empty or tl.empty: + out["htf_dir"] = np.nan + out["htf_agree"] = np.nan + return out + + ts_ltf = timestamps_ms(df_ltf) + entry_ts = ts_ltf[out["entry_idx"].to_numpy().astype(int)] + k = np.searchsorted(tl["confirm_ts"].to_numpy(), entry_ts, side="right") - 1 + valid = k >= 0 + k_safe = np.clip(k, 0, len(tl) - 1) + + fx_dir = tl["direction"].to_numpy()[k_safe].astype(float) + out["htf_dir"] = np.where(valid, fx_dir, np.nan) + out["htf_agree"] = np.where( + valid, (fx_dir == out["direction"].to_numpy()).astype(float), np.nan + ) + return out + + +def attach_zone_ladder(sig: pd.DataFrame, zones: pd.DataFrame) -> pd.DataFrame: + """按信号方向取该中枢的阶梯标记:买看 z_above、卖看 z_below。 + + zone_i 是 find_fast_bsp3 内 enumerate 出的位置序号,故用 iloc 定位。 + """ + out = sig.copy() + if sig.empty: + out["ladder_ok"] = pd.Series(dtype=bool) + return out + z = zones if "z_above" in zones.columns else add_zone_ladder(zones) + above = z["z_above"].to_numpy() + below = z["z_below"].to_numpy() + zi = out["zone_i"].to_numpy().astype(int) + ok = np.where(out["direction"].to_numpy() == 1, above[zi], below[zi]) + out["ladder_ok"] = ok.astype(bool) + return out + + +def find_fast_bsp3( + df: pd.DataFrame, + zones: pd.DataFrame, + scan: int = 200, + pullback_win: int = 30, + tol: float = -1.0, + max_per_zone: int = 1, + diag: dict | None = None, + require_touch: bool = False, +) -> pd.DataFrame: + """扫描每个中枢,找突破后回抽不回中枢的入场点。 + + zones 需含 zg / zd / available_ts,且 available_ts 已是可用时刻。 + max_per_zone > 1 时,同一中枢在首次入场后继续往后找二次、三次突破回抽, + 用来检验「趋势里同一中枢反复给机会」是否值得做。 + + tol 是「算作回抽中」的边界容差。require_touch=False 时它不再是入场门槛, + 仅决定哪些K线被视为回抽中而跳过转强判定,故 tol 越大入场越晚。 + 默认负值 = 完全禁用该跳过,突破后每根都检查转强,滞后压到 2.2 根。 + step34 实测滞后与收益严格单调:9.9根 PF1.88 / 5.8根 2.37 / 2.2根 2.86。 + + 返回列: + entry_idx 实时可下单的K线 + direction +1 三买 / -1 三卖 + bo_idx 突破根 + pb_idx 回抽极值根 + lag entry_idx - bo_idx + depth 回抽深度(相对中枢边界,负值表示曾插入中枢) + occ 这是该中枢的第几次入场 + """ + if zones.empty: + return pd.DataFrame() + + ts = df["timestamp"].to_numpy() + close = df["close"].to_numpy(dtype=float) + high = df["high"].to_numpy(dtype=float) + low = df["low"].to_numpy(dtype=float) + n = len(df) + rows = [] + + def note(key: str) -> None: + if diag is not None: + diag[key] = diag.get(key, 0) + 1 + + for zone_i, (_, z) in enumerate(zones.iterrows()): + note("中枢总数") + zg, zd = float(z["zg"]), float(z["zd"]) + if zg <= zd: + note("×无效中枢") + continue + start = int(np.searchsorted(ts, z["available_ts"], side="left")) + if start >= n - 2: + note("×中枢太靠后") + continue + # 允许多次入场时按比例放宽扫描窗口,否则后几次机会会被窗口截断 + scan_end = min(start + scan * max_per_zone, n) + cursor = start + + for occ in range(1, max_per_zone + 1): + if cursor >= n - 2: + break + # 第一步:找突破。要求突破前确实待在中枢内,避免把远处的价格当突破。 + was_inside = False + bo_idx, d = None, 0 + for j in range(cursor, scan_end): + c = close[j] + if zd <= c <= zg: + was_inside = True + continue + if not was_inside: + continue + bo_idx, d = j, (1 if c > zg else -1) + break + if bo_idx is None: + if occ == 1: + note("×窗口内未突破") + break + + edge = zg if d == 1 else zd + # 第二步:突破后监控回抽,回抽不跌回中枢且重新顺势 -> 入场 + touched = False + pb_idx = None + pb_ext = None + entry_idx = None + fell_back = False + for j in range(bo_idx + 1, min(bo_idx + pullback_win + 1, n)): + # 收盘跌回中枢 -> 突破失效 + if zd <= close[j] <= zg: + fell_back = True + break + # 回抽触及边界附近(允许 tol 的毛刺) + near = (low[j] <= edge * (1 + tol)) if d == 1 else (high[j] >= edge * (1 - tol)) + if near: + touched = True + ext = low[j] if d == 1 else high[j] + if pb_ext is None or ((ext < pb_ext) if d == 1 else (ext > pb_ext)): + pb_ext, pb_idx = ext, j + continue + # 回抽后重新顺势:收盘创出前一根之上(三买)/ 之下(三卖) + # require_touch=False 时不强求回抽碰到中枢边界, + # 这样「突破后一去不回头」的强势段也能收进来。 + if (touched and pb_idx is not None) or not require_touch: + go = close[j] > high[j - 1] if d == 1 else close[j] < low[j - 1] + if go: + entry_idx = j + break + if entry_idx is None: + if occ == 1: + note("×突破后跌回中枢" if fell_back + else "×回抽未触及边界" if not touched + else "×触及边界但未转强") + # 这次突破没走成,从突破点之后继续找下一次 + cursor = bo_idx + 1 + continue + if pb_ext is None: + # 未触及边界就转强(require_touch=False):取突破至入场间的实际极值 + seg = slice(bo_idx + 1, entry_idx + 1) + pb_ext = float(low[seg].min() if d == 1 else high[seg].max()) + pb_idx = int((low[seg].argmin() if d == 1 else high[seg].argmax()) + + bo_idx + 1) + if occ == 1: + note("√成交") + + # 回抽深度:>0 表示未插入中枢,越大表示回抽越浅 + depth = (pb_ext - zg) / zg if d == 1 else (zd - pb_ext) / zd + rows.append({ + "entry_idx": entry_idx, "direction": d, + "bo_idx": bo_idx, "pb_idx": pb_idx, + "lag": entry_idx - bo_idx, + "depth": depth, + "zg": zg, "zd": zd, + "width_pct": (zg - zd) / close[bo_idx], + "occ": occ, + "zone_i": zone_i, + }) + cursor = entry_idx + 1 + + out = pd.DataFrame(rows) + if out.empty: + return out + # 相邻中枢可能突破到同一根K线,同一时刻只能有一个仓位,保留最早成型的那个 + return (out.sort_values(["entry_idx", "occ", "zone_i"]) + .drop_duplicates("entry_idx", keep="first") + .reset_index(drop=True)) diff --git a/chanlun/core/ChanEnum.py b/chanlun/core/ChanEnum.py index 9164374..951ac7f 100644 --- a/chanlun/core/ChanEnum.py +++ b/chanlun/core/ChanEnum.py @@ -281,6 +281,10 @@ class Chan_BSP_TYPE(Enum): S1 = auto() S2 = auto() S3 = auto() + # 第四类:三类买卖点的低滞后变体,几何位置同 B3/S3,但不等笔确认。 + # 见 chanlun/analysis/fast_bsp.py + B4 = auto() + S4 = auto() NONE = auto() """ class Chan_BSP_TYPE(Enum): diff --git a/chanlun/core/ChanFastBSP.py b/chanlun/core/ChanFastBSP.py new file mode 100644 index 0000000..b80a9a7 --- /dev/null +++ b/chanlun/core/ChanFastBSP.py @@ -0,0 +1,40 @@ +from chanlun.core.ChanEnum import Chan_BSP_DIR, Chan_BSP_TYPE + + +class ChanFastBSP(): + """第四类买卖点(B4/S4)。 + + 与 ChanBSP 的区别在于它不挂在笔上:fast_bsp 刻意不等笔确认,入场点是一根具体的 + K线而非一笔的端点,所以时间与价格直接取自 K 线,没有 bi / klc 可依附。 + + htf_agree 与 ladder_ok 是两个独立的过滤标志,不在这里合成——上层(图表或策略) + 自己决定要不要用、怎么组合。 + """ + + def __init__(self, time, price, ddir, entry_idx, bo_time=None, pb_time=None, + lag=0, depth=0.0, zg=None, zd=None, occ=1, + htf_dir=None, htf_agree=None, ladder_ok=None): + self.time = time + self.price = float(price) + self.dir = ddir + self.type = Chan_BSP_TYPE.B4 if ddir == Chan_BSP_DIR.BUY else Chan_BSP_TYPE.S4 + self.entry_idx = int(entry_idx) + self.bo_time = bo_time + self.pb_time = pb_time + self.lag = int(lag) + self.depth = float(depth) + self.zg = float(zg) if zg is not None else None + self.zd = float(zd) if zd is not None else None + self.occ = int(occ) + self.htf_dir = htf_dir + self.htf_agree = htf_agree + self.ladder_ok = ladder_ok + # 入场即成立,没有「等待确认」这个状态;留此字段是为了与 ChanBSP 的序列化对齐 + self.is_sure = True + self.start_time = time + self.end_time = time + self.sure_time = time + + def __repr__(self): + name = str(self.type).replace('Chan_BSP_TYPE.', '') + return f"" diff --git a/chanlun/pipeline/builders/fast_bsp.py b/chanlun/pipeline/builders/fast_bsp.py new file mode 100644 index 0000000..6e9b928 --- /dev/null +++ b/chanlun/pipeline/builders/fast_bsp.py @@ -0,0 +1,148 @@ +"""第四类买卖点(B4/S4)接入 TF_DF。 + +判定逻辑全在 chanlun/analysis/fast_bsp.py,这里只负责把引擎的中枢/K线喂进去, +再把结果包成 ChanFastBSP。 + +刻意不在 init_TF_DF 里默认计算:现有构造路径的开销保持不变,由调用方按需触发。 +""" +from __future__ import annotations + +import re + +import pandas as pd + +from chanlun.analysis.fast_bsp import ( + add_zone_ladder, + attach_htf_agree, + attach_zone_ladder, + ensure_timestamp, + find_fast_bsp3, + htf_fx_timeline, + zones_from_zs_list, +) +from chanlun.core.ChanEnum import Chan_BSP_DIR +from chanlun.core.ChanFastBSP import ChanFastBSP + +# 区间套配对:小级别出中枢与买卖点,大级别只出分型定方向。 +# 取值来自 research/HANDOFF.md §1.5,是回测里实际用过的组合。 +FAST_BSP_HTF_PAIR = { + '1m': '5m', + '5m': '30m', + '15m': '1h', + '30m': '2h', +} + +# 未列入配对表的周期回落到这个倍数 +FAST_BSP_HTF_FALLBACK_RATIO = 4 + +_TF_UNIT_MINUTES = {'m': 1, 'h': 60, 'd': 1440, 'w': 10080} + + +def timeframe_minutes(tf: str) -> int | None: + """'30m' -> 30,'2h' -> 120。无法解析时返回 None。""" + if not tf: + return None + m = re.fullmatch(r'(\d+)\s*([mhdw])', str(tf).strip().lower()) + if not m: + return None + return int(m.group(1)) * _TF_UNIT_MINUTES[m.group(2)] + + +def resolve_htf(tf: str) -> tuple[str, int] | None: + """给小级别找配套的大级别,返回 (标签, 分钟数)。""" + minutes = timeframe_minutes(tf) + if minutes is None: + return None + paired = FAST_BSP_HTF_PAIR.get(str(tf).strip().lower()) + if paired: + return paired, timeframe_minutes(paired) + return f'{minutes * FAST_BSP_HTF_FALLBACK_RATIO}m', minutes * FAST_BSP_HTF_FALLBACK_RATIO + + +class FastBspBuilderMixin: + def build_fast_bsp_htf(self, df, timeframe=None): + """对同一份 df 重采样得到大级别,不额外拉数据。 + + 大级别只用来取分型方向,样本太少就没有过滤意义,故重采样后不足 60 根时放弃。 + """ + tf = timeframe or getattr(self, 'timeframe', None) + htf = resolve_htf(tf) + ltf_minutes = timeframe_minutes(tf) + if htf is None or not ltf_minutes: + return None + label, minutes = htf + if not minutes or len(df) * ltf_minutes < minutes * 60: + return None + try: + from chanlun.pipeline.timeframe import TF_DF + + return TF_DF(df, minutes, label) + except Exception: + return None + + def cal_fast_bsp(self, df=None, bi_zs_list=None, htf_chan=None, with_htf=True, + timeframe=None, **kw): + """算第四类买卖点,返回 ChanFastBSP 列表。 + + bi_zs_list 传入已算好的 pure 笔中枢可免去重复计算。 + with_htf=False 时跳过大级别构建,只留 ladder_ok 这一个过滤标志。 + kw 透传给 find_fast_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 diff --git a/chanlun/pipeline/orchestrator.py b/chanlun/pipeline/orchestrator.py index 788ef14..8188a27 100644 --- a/chanlun/pipeline/orchestrator.py +++ b/chanlun/pipeline/orchestrator.py @@ -169,6 +169,8 @@ class ChanLun(): return self.tf_df.find_second_bsp(bi_list, first_bsp_list) def find_all_bsp(self, bi_list, bi_zs_list): return self.tf_df.find_all_bsp(bi_list, bi_zs_list) + def cal_fast_bsp(self, df=None, bi_zs_list=None, htf_chan=None, with_htf=True, timeframe=None, **kw): + return self.tf_df.cal_fast_bsp(df, bi_zs_list, htf_chan, with_htf, timeframe, **kw) def get_zs_list(self, bi_list, seg_list): return self.tf_df.get_zs_list(bi_list, seg_list) def cal_bi_zs(self, seg_list): diff --git a/chanlun/pipeline/timeframe.py b/chanlun/pipeline/timeframe.py index bbfc1b1..992d347 100644 --- a/chanlun/pipeline/timeframe.py +++ b/chanlun/pipeline/timeframe.py @@ -30,13 +30,14 @@ from chanlun.core.ChanZS import ChanZS, ChanZS_Big from chanlun.indicators.ChanMACD import ChanMACD from chanlun.pipeline.builders.bi import BiBuilderMixin from chanlun.pipeline.builders.bsp import BspBuilderMixin +from chanlun.pipeline.builders.fast_bsp import FastBspBuilderMixin from chanlun.pipeline.builders.incremental import IncrementalBuilderMixin from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin from chanlun.pipeline.builders.kline import KlineBuilderMixin from chanlun.pipeline.builders.seg import SegBuilderMixin from chanlun.pipeline.builders.zs import ZsBuilderMixin -class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin, IncrementalBuilderMixin): +class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin, FastBspBuilderMixin, IncrementalBuilderMixin): def __init__(self, df=None, interval=0, timeframe=None): if df is not None: self.init_TF_DF(df, interval, timeframe) @@ -61,6 +62,7 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde self.zs_list = [] self.bi_zs_list = [] self.bsp_list = [] + self.fast_bsp_list = [] self.seg_list = [] self.klc_fx_list = [] self.klu_list = self.cal_kl_data(self.dataframe) diff --git a/research/lib/fast_bsp3.py b/research/lib/fast_bsp3.py index 21e3ae8..f23b85d 100644 --- a/research/lib/fast_bsp3.py +++ b/research/lib/fast_bsp3.py @@ -1,170 +1,15 @@ -"""快速三类买卖点:不等笔确认,突破回抽当根即入场。 +"""快速三类买卖点 —— 实现已移入引擎 `chanlun.analysis.fast_bsp`。 -引擎的 B3/S3 要等 pullback_bi.sure_time(回拉笔被确认),滞后 9~10 根, -此时价格已从回抽低点反弹完毕,入场价被吃掉。 - -但三买的形态条件本身是实时可判的: - 中枢已成 -> 收盘突破 zg -> 收盘未跌回中枢 -> 重新上行 -最后一步发生的当根就能下单,滞后约 2 根。 - -require_touch 控制是否强求回抽碰到中枢边界。step32/33 的实测表明 -强求反而更差:这等于排除掉「突破后一去不回头」的强势段,而那正是 -缠论里最强的趋势形态。故默认 False(笔数 +21%、滞后 -1 根、PF 2.26→2.37)。 - -判据本身(收盘越过前一根极值)没有独立预测力:裸用 15 万笔样本 PF 0.95, -把中枢换成「近20根高点」这类伪阻力位后 PF 0.90。alpha 全部来自中枢结构, -判据只负责在这个已知价位上确认动能恢复。它也不能用于识别笔端点—— -入场前的回抽极值命中笔端点±2根的比例 19.3%,低于随机基准 22%。 - -全部判定只使用当根及之前的数据,无未来函数。 +这里只做转发,保证 step 脚本里的 `from lib.fast_bsp3 import find_fast_bsp3` 不用改, +同时让回测与 web 图表共用同一份代码。设计说明见引擎模块的 docstring。 """ from __future__ import annotations -import numpy as np -import pandas as pd +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -def find_fast_bsp3( - df: pd.DataFrame, - zones: pd.DataFrame, - scan: int = 200, - pullback_win: int = 30, - tol: float = -1.0, - max_per_zone: int = 1, - diag: dict | None = None, - require_touch: bool = False, -) -> pd.DataFrame: - """扫描每个中枢,找突破后回抽不回中枢的入场点。 +from chanlun.analysis.fast_bsp import find_fast_bsp3 # noqa: F401,E402 - zones 需含 zg / zd / available_ts,且 available_ts 已是可用时刻。 - max_per_zone > 1 时,同一中枢在首次入场后继续往后找二次、三次突破回抽, - 用来检验「趋势里同一中枢反复给机会」是否值得做。 - - tol 是「算作回抽中」的边界容差。require_touch=False 时它不再是入场门槛, - 仅决定哪些K线被视为回抽中而跳过转强判定,故 tol 越大入场越晚。 - 默认负值 = 完全禁用该跳过,突破后每根都检查转强,滞后压到 2.2 根。 - step34 实测滞后与收益严格单调:9.9根 PF1.88 / 5.8根 2.37 / 2.2根 2.86。 - - 返回列: - entry_idx 实时可下单的K线 - direction +1 三买 / -1 三卖 - bo_idx 突破根 - pb_idx 回抽极值根 - lag entry_idx - bo_idx - depth 回抽深度(相对中枢边界,负值表示曾插入中枢) - occ 这是该中枢的第几次入场 - """ - if zones.empty: - return pd.DataFrame() - - ts = df["timestamp"].to_numpy() - close = df["close"].to_numpy(dtype=float) - high = df["high"].to_numpy(dtype=float) - low = df["low"].to_numpy(dtype=float) - n = len(df) - rows = [] - - def note(key: str) -> None: - if diag is not None: - diag[key] = diag.get(key, 0) + 1 - - for zone_i, (_, z) in enumerate(zones.iterrows()): - note("中枢总数") - zg, zd = float(z["zg"]), float(z["zd"]) - if zg <= zd: - note("×无效中枢") - continue - start = int(np.searchsorted(ts, z["available_ts"], side="left")) - if start >= n - 2: - note("×中枢太靠后") - continue - # 允许多次入场时按比例放宽扫描窗口,否则后几次机会会被窗口截断 - scan_end = min(start + scan * max_per_zone, n) - cursor = start - - for occ in range(1, max_per_zone + 1): - if cursor >= n - 2: - break - # 第一步:找突破。要求突破前确实待在中枢内,避免把远处的价格当突破。 - was_inside = False - bo_idx, d = None, 0 - for j in range(cursor, scan_end): - c = close[j] - if zd <= c <= zg: - was_inside = True - continue - if not was_inside: - continue - bo_idx, d = j, (1 if c > zg else -1) - break - if bo_idx is None: - if occ == 1: - note("×窗口内未突破") - break - - edge = zg if d == 1 else zd - # 第二步:突破后监控回抽,回抽不跌回中枢且重新顺势 -> 入场 - touched = False - pb_idx = None - pb_ext = None - entry_idx = None - fell_back = False - for j in range(bo_idx + 1, min(bo_idx + pullback_win + 1, n)): - # 收盘跌回中枢 -> 突破失效 - if zd <= close[j] <= zg: - fell_back = True - break - # 回抽触及边界附近(允许 tol 的毛刺) - near = (low[j] <= edge * (1 + tol)) if d == 1 else (high[j] >= edge * (1 - tol)) - if near: - touched = True - ext = low[j] if d == 1 else high[j] - if pb_ext is None or ((ext < pb_ext) if d == 1 else (ext > pb_ext)): - pb_ext, pb_idx = ext, j - continue - # 回抽后重新顺势:收盘创出前一根之上(三买)/ 之下(三卖) - # require_touch=False 时不强求回抽碰到中枢边界, - # 这样「突破后一去不回头」的强势段也能收进来。 - if (touched and pb_idx is not None) or not require_touch: - go = close[j] > high[j - 1] if d == 1 else close[j] < low[j - 1] - if go: - entry_idx = j - break - if entry_idx is None: - if occ == 1: - note("×突破后跌回中枢" if fell_back - else "×回抽未触及边界" if not touched - else "×触及边界但未转强") - # 这次突破没走成,从突破点之后继续找下一次 - cursor = bo_idx + 1 - continue - if pb_ext is None: - # 未触及边界就转强(require_touch=False):取突破至入场间的实际极值 - seg = slice(bo_idx + 1, entry_idx + 1) - pb_ext = float(low[seg].min() if d == 1 else high[seg].max()) - pb_idx = int((low[seg].argmin() if d == 1 else high[seg].argmax()) - + bo_idx + 1) - if occ == 1: - note("√成交") - - # 回抽深度:>0 表示未插入中枢,越大表示回抽越浅 - depth = (pb_ext - zg) / zg if d == 1 else (zd - pb_ext) / zd - rows.append({ - "entry_idx": entry_idx, "direction": d, - "bo_idx": bo_idx, "pb_idx": pb_idx, - "lag": entry_idx - bo_idx, - "depth": depth, - "zg": zg, "zd": zd, - "width_pct": (zg - zd) / close[bo_idx], - "occ": occ, - "zone_i": zone_i, - }) - cursor = entry_idx + 1 - - out = pd.DataFrame(rows) - if out.empty: - return out - # 相邻中枢可能突破到同一根K线,同一时刻只能有一个仓位,保留最早成型的那个 - return (out.sort_values(["entry_idx", "occ", "zone_i"]) - .drop_duplicates("entry_idx", keep="first") - .reset_index(drop=True)) +__all__ = ["find_fast_bsp3"] diff --git a/research/lib/nested_level.py b/research/lib/nested_level.py index e23c809..cd06d0c 100644 --- a/research/lib/nested_level.py +++ b/research/lib/nested_level.py @@ -17,43 +17,8 @@ import pandas as pd sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from chanlun import TF_DF - - -def build_htf_zones(df_htf: pd.DataFrame, tf: str, chan: TF_DF | None = None) -> pd.DataFrame: - """算 pure 笔中枢,返回带生效时间的区间表。 - - available_ts —— 该中枢最早可被使用的时间戳(其确认时刻)。 - 传入已构建好的 chan 可避免重复跑一遍 pipeline(大数据集上省一半时间)。 - """ - if chan is None: - chan = TF_DF(df_htf, 1, tf) - zs_list = chan.cal_bi_zs_list_pure(chan.bi_list) - # 用引擎自己的 dataframe 对齐,避免调用方传入的 df 与引擎内部行数不一致 - src = chan.dataframe if getattr(chan, "dataframe", None) is not None else df_htf - ts_of = dict(zip(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"), src["timestamp"])) - - 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 "") - avail = ts_of.get(sure_key) or ts_of.get(end_key) - if avail is None: - continue - start_key = str(bis[0].start_time) - rows.append({ - "zg": float(zs.zg), "zd": float(zs.zd), - "gg": float(getattr(zs, "gg", zs.zg)), "dd": float(getattr(zs, "dd", zs.zd)), - "start_ts": ts_of.get(start_key, avail), - "available_ts": int(avail), - }) - out = pd.DataFrame(rows) - return out.sort_values("available_ts").reset_index(drop=True) if not out.empty else out +# build_htf_zones 已移入引擎,与 web 共用同一份实现;annotate_position 仍是研究专用 +from chanlun.analysis.fast_bsp import build_htf_zones # noqa: F401 def annotate_position( diff --git a/web/api/analyze.py b/web/api/analyze.py index 38b73b6..a4a808e 100644 --- a/web/api/analyze.py +++ b/web/api/analyze.py @@ -241,7 +241,9 @@ def analyze(): 'is_sure': bool(bsp.is_sure), 'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None, 'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0 - } for bsp in analysis_result.get('bsp_list', [])] + } for bsp in analysis_result.get('bsp_list', [])], + # 添加主周期第四类买卖点(B4/S4,低滞后三类买卖点) + 'fast_bsp_list': serialize_fast_bsp_list(analysis_result.get('fast_bsp_list', []), client_tz) }) @@ -425,6 +427,7 @@ def analyze(): 'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None, 'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0 } for bsp in element_analysis.get('bsp_list', [])] + result['element_fast_bsp_list'] = serialize_fast_bsp_list(element_analysis.get('fast_bsp_list', []), client_tz) # 次次周期:仅当已指定次周期且次次周期有效时获取 if sub_sub_timeframe and is_smaller_or_equal_timeframe(sub_sub_timeframe, element_timeframe): @@ -526,6 +529,7 @@ def analyze(): 'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None, 'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0 } for bsp in sub_sub_analysis.get('bsp_list', [])] + result['sub_sub_fast_bsp_list'] = serialize_fast_bsp_list(sub_sub_analysis.get('fast_bsp_list', []), client_tz) result['sub_sub_chan_macd'] = serialize_chan_macd_data(sub_sub_analysis.get('chan_macd', {}), client_tz) try: sub_sub_klc_trend = [] diff --git a/web/services/runtime/__init__.py b/web/services/runtime/__init__.py index b05c056..bcb28bb 100644 --- a/web/services/runtime/__init__.py +++ b/web/services/runtime/__init__.py @@ -67,6 +67,7 @@ from .serialize import ( # noqa: F401 convert_direction, format_time_safely, serialize_chan_macd_data, + serialize_fast_bsp_list, clean_dataframe_for_json, get_uncompleted_seg_list, ) diff --git a/web/services/runtime/analyze.py b/web/services/runtime/analyze.py index 274045e..b9f10c3 100644 --- a/web/services/runtime/analyze.py +++ b/web/services/runtime/analyze.py @@ -30,6 +30,15 @@ def analyze_chan(df, symbol=None, timeframe=None): bsp_list = [] if len(bi_zs_list) > 0: bsp_list = chan.find_all_bsp(bi_list, bi_zs_list) + # 第四类买卖点(B4/S4):复用上面刚算好的 bi_zs_list,不重复建中枢。 + # 大级别由同一份 df 重采样得到,失败时退化为空列表,不拖垮主分析。 + fast_bsp_list = [] + try: + if len(bi_zs_list) > 0: + fast_bsp_list = chan.cal_fast_bsp(df=df, bi_zs_list=bi_zs_list, timeframe=timeframe) + except Exception as e: + print(f"第四类买卖点计算出错: {e}") + fast_bsp_list = [] #bsp_state_list = chan.get_bsp_state(df) #for bsp in bsp_list: #print(bsp.end_time, bsp.type, bsp.dir) @@ -177,6 +186,7 @@ def analyze_chan(df, symbol=None, timeframe=None): 'zs_list': zs_list, 'bi_zs_list': bi_zs_list, # 添加BI中枢列表 'bsp_list': bsp_list, # 添加买卖点列表 + 'fast_bsp_list': fast_bsp_list, # 第四类买卖点(B4/S4) 'klc_fx_info': klc_fx_info, # KLC分型信息 'chan_macd': chan_macd_data, # 添加ChanMACD分析数据 'ema52_dict': ema52_dict # 添加多时间周期EMA52数据 diff --git a/web/services/runtime/serialize.py b/web/services/runtime/serialize.py index fc20c38..de75046 100644 --- a/web/services/runtime/serialize.py +++ b/web/services/runtime/serialize.py @@ -31,6 +31,36 @@ def format_time_safely(time_obj, client_tz): # 已经是datetime对象 return time_obj.astimezone(client_tz).isoformat() +def serialize_fast_bsp_list(fast_bsp_list, client_tz): + """序列化第四类买卖点(ChanFastBSP)。 + + htf_agree(大级别分型同向)与 ladder_ok(中枢顺向推进)分别输出, + 由前端决定要显示全部还是只显示两者都满足的。 + """ + out = [] + for bsp in fast_bsp_list or []: + try: + out.append({ + 'time': format_time_safely(bsp.time, client_tz), + 'price': float(bsp.price), + 'type': str(bsp.type).split('.')[-1].split('(')[0], + 'dir': str(bsp.dir).split('.')[-1].split('(')[0], + 'is_sure': True, + 'lag': int(bsp.lag), + 'depth': float(bsp.depth), + 'zg': bsp.zg, + 'zd': bsp.zd, + 'occ': int(bsp.occ), + 'htf_agree': bsp.htf_agree, + 'ladder_ok': bsp.ladder_ok, + 'bo_time': format_time_safely(bsp.bo_time, client_tz) if bsp.bo_time else None, + 'pb_time': format_time_safely(bsp.pb_time, client_tz) if bsp.pb_time else None, + }) + except Exception as e: + print(f"序列化fast_bsp出错: {e}") + continue + return out + def serialize_chan_macd_data(chan_macd_data, client_tz): """序列化ChanMACD数据为JSON可序列化格式""" serialized_data = { diff --git a/web/static/js/app/chart_tv_overlays.js b/web/static/js/app/chart_tv_overlays.js index bee3542..0936d5e 100644 --- a/web/static/js/app/chart_tv_overlays.js +++ b/web/static/js/app/chart_tv_overlays.js @@ -852,6 +852,8 @@ function chartTvRenderOverlays(ctx) { 'BSP1_SELL': { color: '#00E676', text: 'S1', position: 'aboveBar', size: 0.5 }, 'BSP2_SELL': { color: '#00B0FF', text: 'S2', position: 'aboveBar', size: 0.5 }, 'BSP3_SELL': { color: '#8B4513', text: 'S3', position: 'aboveBar', size: 0.5 }, + 'BSP4_BUY': { color: '#FF6D00', text: 'B4', position: 'belowBar', size: 0.5 }, + 'BSP4_SELL': { color: '#0091EA', text: 'S4', position: 'aboveBar', size: 0.5 }, }; const getBspStyleKey = (bsp) => { @@ -987,7 +989,57 @@ function chartTvRenderOverlays(ctx) { // 关闭 BSP 显示时,清空全局 BSP 标记 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` 复选框 @@ -2113,7 +2165,8 @@ function chartTvRenderOverlays(ctx) { ...(window.kluDivMarkersElement || []), ...(window.kluDivMarkersSubSub || []), ...trendMarkersToUse, - ...(window.bspMarkers || []) + ...(window.bspMarkers || []), + ...(window.fastBspMarkers || []) ]; if (combinedMarkers.length > 0) { console.log( @@ -2122,6 +2175,7 @@ function chartTvRenderOverlays(ctx) { '个,小周期分型:', allElementFxMarkers.length, '个,UnitTF:', (window.unittfMarkers || []).length, '个,BSP标记:', (window.bspMarkers || []).length, + '个,第四类标记:', (window.fastBspMarkers || []).length, '个)' ); @@ -2223,14 +2277,15 @@ function chartTvRenderOverlays(ctx) { // 这里的 onlyMainAndU 实际上是「最终要挂到主K线上」的一组标记 // 之前没有把 window.bspMarkers 合进去,导致上面已经合并了 BSP 标记, // 但在这里再次调用 setMarkers 时把 BSP 覆盖掉了,从而前端看不到买卖点。 - // 修复:把 BSP 标记一并合并进来。 + // 修复:把 BSP 标记一并合并进来。第四类买卖点同理,两处都要带上。 const onlyMainAndU = [ ...(window.mainFxMarkers || []), ...(window.kluDivMarkersMain || []), ...(window.kluDivMarkersElement || []), ...(window.kluDivMarkersSubSub || []), ...trendMarkersToUse, - ...(window.bspMarkers || []) + ...(window.bspMarkers || []), + ...(window.fastBspMarkers || []) ]; if (onlyMainAndU.length > 0) { console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, ',UnitTF:', (window.unittfMarkers || []).length, ')'); diff --git a/web/static/js/app/ui.js b/web/static/js/app/ui.js index 3a762d4..a41e90a 100644 --- a/web/static/js/app/ui.js +++ b/web/static/js/app/ui.js @@ -649,6 +649,11 @@ $('#showElementBsp').change(function() { updateChartDisplay(); }); +// 第四类买卖点(B4/S4)显示开关与过滤模式 +$('#showMainFastBsp, #showElementFastBsp, #showSubSubFastBsp, #fastBspFilterMode').change(function() { + updateChartDisplay(); +}); + // 在控制台输出当前显示状态 console.log('当前显示状态:', { 'showOriginalKline': $('#showOriginalKline').is(':checked'), diff --git a/web/templates/index.html b/web/templates/index.html index cc2accc..af2e883 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1017,6 +1017,17 @@ +
+ + +
+
+ +
@@ -1059,6 +1070,10 @@
+
+ + +
@@ -1101,6 +1116,10 @@
+
+ + +
@@ -1269,12 +1288,12 @@ - + - + diff --git a/web/tests/fixtures/analyze_contract_keys.json b/web/tests/fixtures/analyze_contract_keys.json index e855701..3832cfb 100644 --- a/web/tests/fixtures/analyze_contract_keys.json +++ b/web/tests/fixtures/analyze_contract_keys.json @@ -4,6 +4,7 @@ "bi_zs_list", "bsp_list", "chan_macd", + "fast_bsp_list", "klc_fx_info", "klc_list", "klc_trend", diff --git a/web/tests/test_analyze_contract.py b/web/tests/test_analyze_contract.py index 69f9ecb..315a7ab 100644 --- a/web/tests/test_analyze_contract.py +++ b/web/tests/test_analyze_contract.py @@ -35,6 +35,7 @@ ANALYZE_CHAN_KEYS = { "zs_list", "bi_zs_list", "bsp_list", + "fast_bsp_list", "klc_fx_info", "chan_macd", "ema52_dict", @@ -87,7 +88,7 @@ def test_klines_recent_returns_tail_only(): def test_contract_keys_stable(): assert "bi_list" in CONTRACT_KEYS and "seg_list" in CONTRACT_KEYS - for k in ("kline_data", "macd", "zs_list", "bsp_list", "chan_macd"): + for k in ("kline_data", "macd", "zs_list", "bsp_list", "fast_bsp_list", "chan_macd"): assert k in CONTRACT_KEYS