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:
@@ -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))
|
||||
@@ -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):
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
+8
-163
@@ -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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
+5
-1
@@ -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 = []
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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数据
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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, ')');
|
||||
|
||||
@@ -649,6 +649,11 @@ $('#showElementBsp').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 第四类买卖点(B4/S4)显示开关与过滤模式
|
||||
$('#showMainFastBsp, #showElementFastBsp, #showSubSubFastBsp, #fastBspFilterMode').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 在控制台输出当前显示状态
|
||||
console.log('当前显示状态:', {
|
||||
'showOriginalKline': $('#showOriginalKline').is(':checked'),
|
||||
|
||||
@@ -1017,6 +1017,17 @@
|
||||
<input class="form-check-input" type="checkbox" id="showMainBsp">
|
||||
<label class="form-check-label" for="showMainBsp">买卖点</label>
|
||||
</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 class="d-flex align-items-center mt-1">
|
||||
<label class="form-label me-0 mb-0">次周期:</label>
|
||||
@@ -1059,6 +1070,10 @@
|
||||
<input class="form-check-input" type="checkbox" id="showElementBsp">
|
||||
<label class="form-check-label" for="showElementBsp">买卖点</label>
|
||||
</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 class="d-flex align-items-center mt-1">
|
||||
<label class="form-label me-0 mb-0">次次周期:</label>
|
||||
@@ -1101,6 +1116,10 @@
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubBsp">
|
||||
<label class="form-check-label" for="showSubSubBsp">买卖点</label>
|
||||
</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>
|
||||
@@ -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_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_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.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_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/main.js') }}?v=20260808i"></script>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"bi_zs_list",
|
||||
"bsp_list",
|
||||
"chan_macd",
|
||||
"fast_bsp_list",
|
||||
"klc_fx_info",
|
||||
"klc_list",
|
||||
"klc_trend",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user