feat: 第四类买卖点(B4/S4)融入缠论引擎与 web 展示

研究侧的 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 <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-28 00:05:26 +08:00
co-authored by Cursor
parent 7e339d2a54
commit 9b72173285
17 changed files with 702 additions and 209 deletions
+361
View File
@@ -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 自带 timestampweb 侧的不一定,故按 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_agree1 同向 / 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))
+4
View File
@@ -281,6 +281,10 @@ class Chan_BSP_TYPE(Enum):
S1 = auto() S1 = auto()
S2 = auto() S2 = auto()
S3 = auto() S3 = auto()
# 第四类:三类买卖点的低滞后变体,几何位置同 B3/S3,但不等笔确认。
# 见 chanlun/analysis/fast_bsp.py
B4 = auto()
S4 = auto()
NONE = auto() NONE = auto()
""" """
class Chan_BSP_TYPE(Enum): class Chan_BSP_TYPE(Enum):
+40
View File
@@ -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"<ChanFastBSP {name} {self.time} {self.price} lag={self.lag}>"
+148
View File
@@ -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_bsp3scan / 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
+2
View File
@@ -169,6 +169,8 @@ class ChanLun():
return self.tf_df.find_second_bsp(bi_list, first_bsp_list) return self.tf_df.find_second_bsp(bi_list, first_bsp_list)
def find_all_bsp(self, bi_list, bi_zs_list): def find_all_bsp(self, bi_list, bi_zs_list):
return self.tf_df.find_all_bsp(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): def get_zs_list(self, bi_list, seg_list):
return self.tf_df.get_zs_list(bi_list, seg_list) return self.tf_df.get_zs_list(bi_list, seg_list)
def cal_bi_zs(self, seg_list): def cal_bi_zs(self, seg_list):
+3 -1
View File
@@ -30,13 +30,14 @@ from chanlun.core.ChanZS import ChanZS, ChanZS_Big
from chanlun.indicators.ChanMACD import ChanMACD from chanlun.indicators.ChanMACD import ChanMACD
from chanlun.pipeline.builders.bi import BiBuilderMixin from chanlun.pipeline.builders.bi import BiBuilderMixin
from chanlun.pipeline.builders.bsp import BspBuilderMixin 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.incremental import IncrementalBuilderMixin
from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin
from chanlun.pipeline.builders.kline import KlineBuilderMixin from chanlun.pipeline.builders.kline import KlineBuilderMixin
from chanlun.pipeline.builders.seg import SegBuilderMixin from chanlun.pipeline.builders.seg import SegBuilderMixin
from chanlun.pipeline.builders.zs import ZsBuilderMixin 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): def __init__(self, df=None, interval=0, timeframe=None):
if df is not None: if df is not None:
self.init_TF_DF(df, interval, timeframe) self.init_TF_DF(df, interval, timeframe)
@@ -61,6 +62,7 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde
self.zs_list = [] self.zs_list = []
self.bi_zs_list = [] self.bi_zs_list = []
self.bsp_list = [] self.bsp_list = []
self.fast_bsp_list = []
self.seg_list = [] self.seg_list = []
self.klc_fx_list = [] self.klc_fx_list = []
self.klu_list = self.cal_kl_data(self.dataframe) self.klu_list = self.cal_kl_data(self.dataframe)
+8 -163
View File
@@ -1,170 +1,15 @@
"""快速三类买卖点:不等笔确认,突破回抽当根即入场 """快速三类买卖点 —— 实现已移入引擎 `chanlun.analysis.fast_bsp`
引擎的 B3/S3 要等 pullback_bi.sure_time(回拉笔被确认),滞后 9~10 根 这里只做转发,保证 step 脚本里的 `from lib.fast_bsp3 import find_fast_bsp3` 不用改
此时价格已从回抽低点反弹完毕,入场价被吃掉 同时让回测与 web 图表共用同一份代码。设计说明见引擎模块的 docstring
但三买的形态条件本身是实时可判的:
中枢已成 -> 收盘突破 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%
全部判定只使用当根及之前的数据,无未来函数。
""" """
from __future__ import annotations from __future__ import annotations
import numpy as np import sys
import pandas as pd from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
def find_fast_bsp3( from chanlun.analysis.fast_bsp import find_fast_bsp3 # noqa: F401,E402
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 已是可用时刻。 __all__ = ["find_fast_bsp3"]
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))
+2 -37
View File
@@ -17,43 +17,8 @@ import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2])) sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from chanlun import TF_DF # build_htf_zones 已移入引擎,与 web 共用同一份实现;annotate_position 仍是研究专用
from chanlun.analysis.fast_bsp import build_htf_zones # noqa: F401
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
def annotate_position( def annotate_position(
+5 -1
View File
@@ -241,7 +241,9 @@ def analyze():
'is_sure': bool(bsp.is_sure), 'is_sure': bool(bsp.is_sure),
'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None, '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 '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, '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 'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0
} for bsp in element_analysis.get('bsp_list', [])] } 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): 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, '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 'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0
} for bsp in sub_sub_analysis.get('bsp_list', [])] } 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) result['sub_sub_chan_macd'] = serialize_chan_macd_data(sub_sub_analysis.get('chan_macd', {}), client_tz)
try: try:
sub_sub_klc_trend = [] sub_sub_klc_trend = []
+1
View File
@@ -67,6 +67,7 @@ from .serialize import ( # noqa: F401
convert_direction, convert_direction,
format_time_safely, format_time_safely,
serialize_chan_macd_data, serialize_chan_macd_data,
serialize_fast_bsp_list,
clean_dataframe_for_json, clean_dataframe_for_json,
get_uncompleted_seg_list, get_uncompleted_seg_list,
) )
+10
View File
@@ -30,6 +30,15 @@ def analyze_chan(df, symbol=None, timeframe=None):
bsp_list = [] bsp_list = []
if len(bi_zs_list) > 0: if len(bi_zs_list) > 0:
bsp_list = chan.find_all_bsp(bi_list, bi_zs_list) 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) #bsp_state_list = chan.get_bsp_state(df)
#for bsp in bsp_list: #for bsp in bsp_list:
#print(bsp.end_time, bsp.type, bsp.dir) #print(bsp.end_time, bsp.type, bsp.dir)
@@ -177,6 +186,7 @@ def analyze_chan(df, symbol=None, timeframe=None):
'zs_list': zs_list, 'zs_list': zs_list,
'bi_zs_list': bi_zs_list, # 添加BI中枢列表 'bi_zs_list': bi_zs_list, # 添加BI中枢列表
'bsp_list': bsp_list, # 添加买卖点列表 'bsp_list': bsp_list, # 添加买卖点列表
'fast_bsp_list': fast_bsp_list, # 第四类买卖点(B4/S4
'klc_fx_info': klc_fx_info, # KLC分型信息 'klc_fx_info': klc_fx_info, # KLC分型信息
'chan_macd': chan_macd_data, # 添加ChanMACD分析数据 'chan_macd': chan_macd_data, # 添加ChanMACD分析数据
'ema52_dict': ema52_dict # 添加多时间周期EMA52数据 'ema52_dict': ema52_dict # 添加多时间周期EMA52数据
+30
View File
@@ -31,6 +31,36 @@ def format_time_safely(time_obj, client_tz):
# 已经是datetime对象 # 已经是datetime对象
return time_obj.astimezone(client_tz).isoformat() 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): def serialize_chan_macd_data(chan_macd_data, client_tz):
"""序列化ChanMACD数据为JSON可序列化格式""" """序列化ChanMACD数据为JSON可序列化格式"""
serialized_data = { serialized_data = {
+58 -3
View File
@@ -852,6 +852,8 @@ function chartTvRenderOverlays(ctx) {
'BSP1_SELL': { color: '#00E676', text: 'S1', position: 'aboveBar', size: 0.5 }, 'BSP1_SELL': { color: '#00E676', text: 'S1', position: 'aboveBar', size: 0.5 },
'BSP2_SELL': { color: '#00B0FF', text: 'S2', 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 }, '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) => { const getBspStyleKey = (bsp) => {
@@ -988,6 +990,56 @@ function chartTvRenderOverlays(ctx) {
window.bspMarkers = []; 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` 复选框 // 同时响应顶部的 `#showMainBsp` 复选框
@@ -2113,7 +2165,8 @@ function chartTvRenderOverlays(ctx) {
...(window.kluDivMarkersElement || []), ...(window.kluDivMarkersElement || []),
...(window.kluDivMarkersSubSub || []), ...(window.kluDivMarkersSubSub || []),
...trendMarkersToUse, ...trendMarkersToUse,
...(window.bspMarkers || []) ...(window.bspMarkers || []),
...(window.fastBspMarkers || [])
]; ];
if (combinedMarkers.length > 0) { if (combinedMarkers.length > 0) {
console.log( console.log(
@@ -2122,6 +2175,7 @@ function chartTvRenderOverlays(ctx) {
'个,小周期分型:', allElementFxMarkers.length, '个,小周期分型:', allElementFxMarkers.length,
'个,UnitTF:', (window.unittfMarkers || []).length, '个,UnitTF:', (window.unittfMarkers || []).length,
'个,BSP标记:', (window.bspMarkers || []).length, '个,BSP标记:', (window.bspMarkers || []).length,
'个,第四类标记:', (window.fastBspMarkers || []).length,
'个)' '个)'
); );
@@ -2223,14 +2277,15 @@ function chartTvRenderOverlays(ctx) {
// 这里的 onlyMainAndU 实际上是「最终要挂到主K线上」的一组标记 // 这里的 onlyMainAndU 实际上是「最终要挂到主K线上」的一组标记
// 之前没有把 window.bspMarkers 合进去,导致上面已经合并了 BSP 标记, // 之前没有把 window.bspMarkers 合进去,导致上面已经合并了 BSP 标记,
// 但在这里再次调用 setMarkers 时把 BSP 覆盖掉了,从而前端看不到买卖点。 // 但在这里再次调用 setMarkers 时把 BSP 覆盖掉了,从而前端看不到买卖点。
// 修复:把 BSP 标记一并合并进来。 // 修复:把 BSP 标记一并合并进来。第四类买卖点同理,两处都要带上。
const onlyMainAndU = [ const onlyMainAndU = [
...(window.mainFxMarkers || []), ...(window.mainFxMarkers || []),
...(window.kluDivMarkersMain || []), ...(window.kluDivMarkersMain || []),
...(window.kluDivMarkersElement || []), ...(window.kluDivMarkersElement || []),
...(window.kluDivMarkersSubSub || []), ...(window.kluDivMarkersSubSub || []),
...trendMarkersToUse, ...trendMarkersToUse,
...(window.bspMarkers || []) ...(window.bspMarkers || []),
...(window.fastBspMarkers || [])
]; ];
if (onlyMainAndU.length > 0) { if (onlyMainAndU.length > 0) {
console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, 'UnitTF:', (window.unittfMarkers || []).length, ''); console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, 'UnitTF:', (window.unittfMarkers || []).length, '');
+5
View File
@@ -649,6 +649,11 @@ $('#showElementBsp').change(function() {
updateChartDisplay(); updateChartDisplay();
}); });
// 第四类买卖点(B4/S4)显示开关与过滤模式
$('#showMainFastBsp, #showElementFastBsp, #showSubSubFastBsp, #fastBspFilterMode').change(function() {
updateChartDisplay();
});
// 在控制台输出当前显示状态 // 在控制台输出当前显示状态
console.log('当前显示状态:', { console.log('当前显示状态:', {
'showOriginalKline': $('#showOriginalKline').is(':checked'), 'showOriginalKline': $('#showOriginalKline').is(':checked'),
+21 -2
View File
@@ -1017,6 +1017,17 @@
<input class="form-check-input" type="checkbox" id="showMainBsp"> <input class="form-check-input" type="checkbox" id="showMainBsp">
<label class="form-check-label" for="showMainBsp">买卖点</label> <label class="form-check-label" for="showMainBsp">买卖点</label>
</div> </div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainFastBsp">
<label class="form-check-label" for="showMainFastBsp">第四类</label>
</div>
<div class="form-check form-check-inline">
<select id="fastBspFilterMode" class="form-select form-select-sm" style="width: 130px;"
title="深色为区间套(大级别分型同向)+中枢顺向推进都满足的信号,浅色为未通过过滤">
<option value="all" selected>第四类:全部</option>
<option value="filtered">第四类:仅过滤后</option>
</select>
</div>
</div> </div>
<div class="d-flex align-items-center mt-1"> <div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">次周期:</label> <label class="form-label me-0 mb-0">次周期:</label>
@@ -1059,6 +1070,10 @@
<input class="form-check-input" type="checkbox" id="showElementBsp"> <input class="form-check-input" type="checkbox" id="showElementBsp">
<label class="form-check-label" for="showElementBsp">买卖点</label> <label class="form-check-label" for="showElementBsp">买卖点</label>
</div> </div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementFastBsp">
<label class="form-check-label" for="showElementFastBsp">第四类</label>
</div>
</div> </div>
<div class="d-flex align-items-center mt-1"> <div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">次次周期:</label> <label class="form-label me-0 mb-0">次次周期:</label>
@@ -1101,6 +1116,10 @@
<input class="form-check-input" type="checkbox" id="showSubSubBsp"> <input class="form-check-input" type="checkbox" id="showSubSubBsp">
<label class="form-check-label" for="showSubSubBsp">买卖点</label> <label class="form-check-label" for="showSubSubBsp">买卖点</label>
</div> </div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubFastBsp">
<label class="form-check-label" for="showSubSubFastBsp">第四类</label>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1269,12 +1288,12 @@
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260810e"></script> <script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260810e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260808i"></script> <script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260810d"></script> <script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260810d"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260810e"></script> <script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260827a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260810a"></script> <script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260810a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260808i"></script> <script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260809z"></script> <script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260809z"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260808i"></script> <script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260810e"></script> <script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260827a"></script>
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}?v=20260808i"></script> <script defer src="{{ url_for('static', filename='js/app/overlays.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260808i"></script> <script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260808i"></script>
+1
View File
@@ -4,6 +4,7 @@
"bi_zs_list", "bi_zs_list",
"bsp_list", "bsp_list",
"chan_macd", "chan_macd",
"fast_bsp_list",
"klc_fx_info", "klc_fx_info",
"klc_list", "klc_list",
"klc_trend", "klc_trend",
+2 -1
View File
@@ -35,6 +35,7 @@ ANALYZE_CHAN_KEYS = {
"zs_list", "zs_list",
"bi_zs_list", "bi_zs_list",
"bsp_list", "bsp_list",
"fast_bsp_list",
"klc_fx_info", "klc_fx_info",
"chan_macd", "chan_macd",
"ema52_dict", "ema52_dict",
@@ -87,7 +88,7 @@ def test_klines_recent_returns_tail_only():
def test_contract_keys_stable(): def test_contract_keys_stable():
assert "bi_list" in CONTRACT_KEYS and "seg_list" in CONTRACT_KEYS 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 assert k in CONTRACT_KEYS