refactor: 精简仓库为 chanlun 核心与 web 分析,移除威科夫与遗留模块

删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-27 01:05:12 +08:00
co-authored by Cursor
parent 5c10e35b76
commit 7f393b93ed
360 changed files with 140008 additions and 41167 deletions
+239
View File
@@ -0,0 +1,239 @@
"""突破跟随的事件驱动回测。
趋势跟随是低胜率高赔率,固定持有期会把大赢利截断、把小亏损放大,
必须用止损/止盈/时间三重出场才能测出真实期望。
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
FEE = 0.0008 # 双边
@dataclass
class Trade:
entry_idx: int
exit_idx: int
direction: int
entry: float
exit: float
ret: float # 已扣费
reason: str # sl / tp / time
bars_held: int
gross: float # 未扣费,便于事后做费率 what-if
risk_pct: float # 止损距离占入场价的比例,用于反推名义仓位
boosted: bool = False # 持仓期间是否等到了更大级别的同向确认
def run_trades(
df: pd.DataFrame,
entries: list[tuple[int, int]],
sl_atr: float = 1.5,
tp_atr: float = 3.0,
max_bars: int = 48,
trail: bool = False,
fee: float = FEE,
entry_delay: int = 0,
slippage: float = 0.0,
boost_dir: np.ndarray | None = None,
tp_boost: float = 2.0,
boost_breakeven: bool = False,
) -> pd.DataFrame:
"""按 (entry_idx, direction) 逐笔模拟。
出场优先级:同一根内若同时触及止损与止盈,保守地判为止损。
entry_delay=1 表示信号次根开盘成交,用来检验「收盘价入场」是否过于乐观。
boost_dir 给出每根K线上「趋势被更大级别确认」的方向(+1/-1/0)。持仓期间
一旦等到同向确认,就把止盈目标放大 tp_boost 倍;boost_breakeven 同时把
止损收到成本价。用来检验「新证据出现后该不该改单」。
"""
high = df["high"].to_numpy(dtype=float)
low = df["low"].to_numpy(dtype=float)
close = df["close"].to_numpy(dtype=float)
open_ = df["open"].to_numpy(dtype=float) if "open" in df.columns else close
atr = (
df["atr"].to_numpy(dtype=float)
if "atr" in df.columns
else pd.Series(close).rolling(14).std().bfill().to_numpy()
)
n = len(df)
out: list[Trade] = []
for e_idx, d in entries:
sig_idx = e_idx
e_idx = e_idx + entry_delay
if e_idx >= n - 1:
continue
entry = open_[e_idx] if entry_delay else close[e_idx]
a = atr[sig_idx]
if not np.isfinite(a) or a <= 0:
continue
sl = entry - d * sl_atr * a
tp = entry + d * tp_atr * a
best = entry
exit_idx, exit_px, reason = None, None, "time"
boosted = False
for j in range(e_idx + 1, min(e_idx + max_bars + 1, n)):
if boost_dir is not None and not boosted and boost_dir[j] == d:
tp = entry + d * tp_atr * tp_boost * a
if boost_breakeven:
sl = max(sl, entry) if d == 1 else min(sl, entry)
boosted = True
if trail:
best = max(best, high[j]) if d == 1 else min(best, low[j])
sl = max(sl, best - sl_atr * a) if d == 1 else min(sl, best + sl_atr * a)
hit_sl = low[j] <= sl if d == 1 else high[j] >= sl
hit_tp = high[j] >= tp if d == 1 else low[j] <= tp
if hit_sl:
exit_idx, exit_px, reason = j, sl, "sl"
break
if hit_tp:
exit_idx, exit_px, reason = j, tp, "tp"
break
if exit_idx is None:
exit_idx = min(e_idx + max_bars, n - 1)
exit_px = close[exit_idx]
gross = d * (exit_px - entry) / entry
out.append(Trade(sig_idx, exit_idx, d, entry, float(exit_px),
gross - fee - slippage, reason, exit_idx - e_idx,
gross, sl_atr * a / entry, boosted))
return pd.DataFrame([t.__dict__ for t in out])
def run_trades_dynamic(
df: pd.DataFrame,
entries: list[tuple[int, int]],
upgrades: dict[int, int],
sl_atr: float = 1.5,
tp_atr: float = 3.0,
tp_atr_up: float = 6.0,
max_bars: int = 48,
max_bars_up: int = 96,
lock_breakeven: bool = True,
fee: float = FEE,
entry_delay: int = 0,
slippage: float = 0.0,
) -> pd.DataFrame:
"""持仓中若出现更大级别的同向确认,就把目标放远、并把止损收到成本价。
upgrades: {K线索引: 方向},表示该根出现了大级别同向三买/中枢突破。
对应的交易逻辑是「小级别进场、大级别接力」——趋势被更高级别确认后,
原本 3 ATR 的目标就过早了,但同时不该再让这笔回到亏损。
"""
high = df["high"].to_numpy(dtype=float)
low = df["low"].to_numpy(dtype=float)
close = df["close"].to_numpy(dtype=float)
open_ = df["open"].to_numpy(dtype=float) if "open" in df.columns else close
atr = (
df["atr"].to_numpy(dtype=float)
if "atr" in df.columns
else pd.Series(close).rolling(14).std().bfill().to_numpy()
)
n = len(df)
out: list[dict] = []
for e_idx, d in entries:
sig_idx = e_idx
e_idx = e_idx + entry_delay
if e_idx >= n - 1:
continue
entry = open_[e_idx] if entry_delay else close[e_idx]
a = atr[sig_idx]
if not np.isfinite(a) or a <= 0:
continue
sl = entry - d * sl_atr * a
tp = entry + d * tp_atr * a
limit = max_bars
upgraded = False
exit_idx, exit_px, reason = None, None, "time"
j = e_idx + 1
while j < min(e_idx + limit + 1, n):
if not upgraded and upgrades.get(j) == d:
upgraded = True
tp = entry + d * tp_atr_up * a
limit = max_bars_up
if lock_breakeven:
sl = max(sl, entry) if d == 1 else min(sl, entry)
hit_sl = low[j] <= sl if d == 1 else high[j] >= sl
hit_tp = high[j] >= tp if d == 1 else low[j] <= tp
if hit_sl:
exit_idx, exit_px, reason = j, sl, "be" if upgraded and sl == entry else "sl"
break
if hit_tp:
exit_idx, exit_px, reason = j, tp, "tp_up" if upgraded else "tp"
break
j += 1
if exit_idx is None:
exit_idx = min(e_idx + limit, n - 1)
exit_px = close[exit_idx]
gross = d * (exit_px - entry) / entry
out.append({
"entry_idx": sig_idx, "exit_idx": exit_idx, "direction": d,
"entry": entry, "exit": float(exit_px),
"ret": gross - fee - slippage, "reason": reason,
"bars_held": exit_idx - e_idx, "gross": gross,
"risk_pct": sl_atr * a / entry, "upgraded": upgraded,
})
return pd.DataFrame(out)
def summarize_trades(tr: pd.DataFrame, label: str) -> dict:
if tr.empty:
return {"策略": label, "笔数": 0}
r = tr["ret"].to_numpy()
win = r[r > 0]
loss = r[r <= 0]
pf = win.sum() / abs(loss.sum()) if len(loss) and loss.sum() != 0 else np.inf
sd = r.std(ddof=1)
eq = np.cumprod(1 + r)
dd = float((1 - eq / np.maximum.accumulate(eq)).max()) if len(eq) else 0.0
return {
"策略": label,
"笔数": len(r),
"胜率": f"{(r > 0).mean() * 100:.1f}%",
"均收益": f"{r.mean() * 100:+.3f}%",
"赔率": f"{(win.mean() / abs(loss.mean())):.2f}" if len(win) and len(loss) else "",
"盈亏比PF": f"{pf:.2f}",
"总收益": f"{(eq[-1] - 1) * 100:+.1f}%",
"最大回撤": f"{dd * 100:.1f}%",
"t值": f"{r.mean() / (sd / np.sqrt(len(r))):+.2f}" if sd else "",
"均持有": f"{tr['bars_held'].mean():.0f}",
}
def find_breakout_entries(
sig: pd.DataFrame, df: pd.DataFrame, window: int = 10, mode: str = "fail"
) -> list[tuple[int, int]]:
"""分型突破入场点。
mode="fail" 分型失败 -> 顺势跟随:顶分型被向上突破则做多。
mode="reverse" 传统反转 -> 分型成立方向:顶分型做空(作为对照)。
"""
close = df["close"].to_numpy(dtype=float)
n = len(df)
entries: list[tuple[int, int]] = []
for _, r in sig.iterrows():
d_fx = int(r["direction"]) # +1 底分型 / -1 顶分型
lvl = float(r["price"])
c0 = int(r["confirm_idx"])
if mode == "reverse":
entries.append((c0, d_fx))
continue
# 突破方向与分型指向相反:顶分型(-1)被向上(+1)突破
d_bo = -d_fx
for j in range(c0 + 1, min(c0 + window + 1, n)):
broken = close[j] > lvl if d_bo == 1 else close[j] < lvl
if broken:
entries.append((j, d_bo))
break
return entries
+200
View File
@@ -0,0 +1,200 @@
"""缠论买卖点信号有效性评估:事件研究(event study)。
核心口径约定:
- 入场时刻一律取 bsp.sure_time(笔被确认的那根K线收盘),而非分型时间 end_time。
分型时间在当时是不可知的,用它回测等于开了未来函数。
- BUY 视为做多,SELL 视为做空,收益按方向调整后统一为正=盈利。
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from chanlun import TF_DF
from chanlun.core.ChanEnum import Chan_BSP_DIR
DEFAULT_HORIZONS = (1, 3, 5, 10, 20, 40)
@dataclass
class BspEvent:
"""一个可交易的买卖点事件。"""
bsp_type: str
direction: int # +1 做多 / -1 做空
fx_time: pd.Timestamp # 分型时间(信号形态出现)
entry_time: pd.Timestamp # 确认时间(可交易)
entry_idx: int
entry_price: float
lag_bars: int # 确认滞后了多少根K线
def build_bi_zs(chan: TF_DF, zs_source: str) -> list:
"""构造笔中枢列表。
seg —— 在每个线段内部找笔中枢,中枢必须等所属线段成形,确认慢一层。
pure —— 直接在扁平笔序列上滚动,不依赖线段,确认更快。
"""
if zs_source == "seg":
return chan.cal_bi_zs(chan.seg_list)
if zs_source == "pure":
return chan.cal_bi_zs_list_pure(chan.bi_list)
raise ValueError(f"未知的中枢来源: {zs_source}")
def run_pipeline(df: pd.DataFrame, tf: str, zs_source: str = "seg") -> tuple[TF_DF, list]:
"""跑完整缠论 pipeline,返回引擎与买卖点列表。"""
chan = TF_DF(df, 1, tf)
bi_zs_list = build_bi_zs(chan, zs_source)
bsp_list = chan.find_all_bsp(chan.bi_list, bi_zs_list) if bi_zs_list else []
return chan, bsp_list
def _time_index_map(df: pd.DataFrame) -> dict[str, int]:
"""K线收盘时间 -> 行号。缠论内部把时间存成无时区字符串,按字符串对齐最稳。"""
keys = df["date"].dt.strftime("%Y-%m-%d %H:%M:%S")
return {k: i for i, k in enumerate(keys)}
def to_events(bsp_list: list, df: pd.DataFrame) -> list[BspEvent]:
"""把 ChanBSP 转成以确认时刻为准的可交易事件。"""
idx_map = _time_index_map(df)
closes = df["close"].to_numpy(dtype=float)
events: list[BspEvent] = []
for bsp in bsp_list:
if not bsp.is_sure or bsp.sure_time is None:
continue
entry_key, fx_key = str(bsp.sure_time), str(bsp.end_time)
if entry_key not in idx_map or fx_key not in idx_map:
continue
entry_idx = idx_map[entry_key]
fx_idx = idx_map[fx_key]
entry_ts = pd.Timestamp(entry_key)
fx_ts = pd.Timestamp(fx_key)
events.append(
BspEvent(
bsp_type=str(bsp.type).replace("Chan_BSP_TYPE.", ""),
direction=1 if bsp.dir == Chan_BSP_DIR.BUY else -1,
fx_time=fx_ts,
entry_time=entry_ts,
entry_idx=entry_idx,
entry_price=float(closes[entry_idx]),
lag_bars=entry_idx - fx_idx,
)
)
return events
def forward_returns(
events: list[BspEvent], df: pd.DataFrame, horizons=DEFAULT_HORIZONS
) -> pd.DataFrame:
"""计算每个事件在各持有期的方向调整收益,以及 MFE/MAE。"""
closes = df["close"].to_numpy(dtype=float)
highs = df["high"].to_numpy(dtype=float)
lows = df["low"].to_numpy(dtype=float)
n = len(df)
rows = []
for ev in events:
row = {
"bsp_type": ev.bsp_type,
"direction": ev.direction,
"side": "LONG" if ev.direction == 1 else "SHORT",
"fx_time": ev.fx_time,
"entry_time": ev.entry_time,
"entry_idx": ev.entry_idx,
"entry_price": ev.entry_price,
"lag_bars": ev.lag_bars,
}
for h in horizons:
j = ev.entry_idx + h
if j >= n:
row[f"ret_{h}"] = np.nan
row[f"mfe_{h}"] = np.nan
row[f"mae_{h}"] = np.nan
continue
seg = slice(ev.entry_idx + 1, j + 1)
row[f"ret_{h}"] = ev.direction * (closes[j] - ev.entry_price) / ev.entry_price
if ev.direction == 1:
best, worst = highs[seg].max(), lows[seg].min()
else:
best, worst = lows[seg].min(), highs[seg].max()
row[f"mfe_{h}"] = ev.direction * (best - ev.entry_price) / ev.entry_price
row[f"mae_{h}"] = ev.direction * (worst - ev.entry_price) / ev.entry_price
rows.append(row)
return pd.DataFrame(rows)
def baseline_stats(df: pd.DataFrame, horizons=DEFAULT_HORIZONS) -> pd.DataFrame:
"""基准:全样本每根K线无条件持有的收益分布(多头视角)。"""
closes = df["close"].to_numpy(dtype=float)
rows = []
for h in horizons:
fwd = (closes[h:] - closes[:-h]) / closes[:-h]
rows.append(
{
"horizon": h,
"base_mean_long": fwd.mean(),
"base_median_long": np.median(fwd),
"base_winrate_long": (fwd > 0).mean(),
"base_std": fwd.std(ddof=1),
}
)
return pd.DataFrame(rows)
def _tstat(x: np.ndarray) -> float:
if len(x) < 2:
return np.nan
sd = x.std(ddof=1)
return np.nan if sd == 0 else float(x.mean() / (sd / np.sqrt(len(x))))
def summarize(
fwd: pd.DataFrame, df: pd.DataFrame, horizons=DEFAULT_HORIZONS, by_type: bool = True
) -> pd.DataFrame:
"""汇总各类买卖点在各持有期的表现,并给出对基准的超额。"""
base = baseline_stats(df, horizons).set_index("horizon")
groups: list[tuple[str, pd.DataFrame]] = [("ALL", fwd)]
if by_type:
groups += [("ALL_LONG", fwd[fwd.direction == 1]), ("ALL_SHORT", fwd[fwd.direction == -1])]
groups += [(t, g) for t, g in fwd.groupby("bsp_type")]
rows = []
for name, g in groups:
if g.empty:
continue
for h in horizons:
r = g[f"ret_{h}"].dropna().to_numpy()
if len(r) == 0:
continue
# 基准需按方向调整:做空的无条件期望是多头期望的相反数
dirs = g.loc[g[f"ret_{h}"].notna(), "direction"].to_numpy()
base_mean = float(np.mean(dirs) * base.loc[h, "base_mean_long"])
rows.append(
{
"group": name,
"horizon": h,
"n": len(r),
"mean": r.mean(),
"median": np.median(r),
"winrate": (r > 0).mean(),
"excess": r.mean() - base_mean,
"tstat": _tstat(r),
"mfe": g[f"mfe_{h}"].dropna().mean(),
"mae": g[f"mae_{h}"].dropna().mean(),
}
)
return pd.DataFrame(rows)
def fmt_pct(x: float) -> str:
return "n/a" if pd.isna(x) else f"{x * 100:+.2f}%"
+108
View File
@@ -0,0 +1,108 @@
"""研究用数据层:本地历史数据优先,回落到远端数据服务。
远端服务的小周期只保留 90 天(派生自 1m),做区间套时样本严重不足。
本项目 data/ 下是完整下载的 BTC/ETH/SOL 全周期数据(1m~1w2172~2543 天),
是主力数据源;旧的 freqtrade 目录作为备用。
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
import requests
DATA_SERVICE_URL = "https://provider.jackyu66.com"
CACHE_DIR = Path(__file__).resolve().parents[1] / ".cache"
NUMERIC_COLS = ("open", "high", "low", "close", "volume")
# 按优先级查找,第一个命中的目录生效
LOCAL_DATA_DIRS = (
Path(__file__).resolve().parents[2] / "data",
Path("/Users/jack/Project/freqtrade/user_data/data"),
)
def _cache_path(symbol: str, tf: str, limit: int) -> Path:
safe = symbol.replace("/", "_").replace(":", "-")
return CACHE_DIR / f"{safe}__{tf}__{limit}.parquet"
def _normalize(raw: list[dict]) -> pd.DataFrame:
df = pd.DataFrame(raw)
df["timestamp"] = pd.to_numeric(df["timestamp"], errors="coerce")
for col in NUMERIC_COLS:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.dropna(subset=["timestamp", *NUMERIC_COLS])
df = df.drop_duplicates(subset=["timestamp"]).sort_values("timestamp").reset_index(drop=True)
df["timestamp"] = df["timestamp"].astype("int64")
df["date"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True).dt.tz_convert("Asia/Shanghai")
return df[["timestamp", "date", *NUMERIC_COLS]]
def _freqtrade_path(symbol: str, tf: str, exchange: str = "binance") -> Path | None:
"""定位 feather 文件。BTC/USDT:USDT -> BTC_USDT_USDT-1h-futures.feather"""
for root in LOCAL_DATA_DIRS:
base = root / exchange
if ":" in symbol: # 永续合约
name = symbol.replace("/", "_").replace(":", "_")
cand = base / "futures" / f"{name}-{tf}-futures.feather"
else:
name = symbol.replace("/", "_")
cand = base / f"{name}-{tf}.feather"
if cand.exists():
return cand
return None
def load_local(symbol: str, tf: str, exchange: str = "binance") -> pd.DataFrame | None:
"""读取 freqtrade 本地历史数据,转成与远端一致的列结构。"""
path = _freqtrade_path(symbol, tf, exchange)
if path is None:
return None
df = pd.read_feather(path)
df = df.rename(columns={c: c.lower() for c in df.columns})
if "date" not in df.columns:
return None
date = pd.to_datetime(df["date"], utc=True)
# 不能用 date.astype("int64")//10**6:该值的单位取决于列的精度
# datetime64[ns] 给纳秒、[ms] 给毫秒),对毫秒精度的文件会把时间戳砸平,
# 进而被 drop_duplicates 删掉九成数据。下面的写法与底层精度无关。
epoch_ms = (date - pd.Timestamp("1970-01-01", tz="UTC")) // pd.Timedelta("1ms")
out = pd.DataFrame({
"timestamp": epoch_ms.astype("int64"),
"date": date.dt.tz_convert("Asia/Shanghai"),
})
for col in NUMERIC_COLS:
out[col] = pd.to_numeric(df[col], errors="coerce") if col in df else 0.0
out = out.dropna(subset=list(NUMERIC_COLS))
return out.drop_duplicates(subset=["timestamp"]).sort_values("timestamp").reset_index(drop=True)
def fetch_ohlcv(
symbol: str,
tf: str,
limit: int = 5000,
refresh: bool = False,
prefer_local: bool = True,
) -> pd.DataFrame:
"""获取K线。优先本地 freqtrade 数据,其次本地缓存,最后回源数据服务。"""
if prefer_local and not refresh:
local = load_local(symbol, tf)
if local is not None and len(local) > 0:
return local.tail(limit).reset_index(drop=True) if limit and len(local) > limit else local
path = _cache_path(symbol, tf, limit)
if path.exists() and not refresh:
return pd.read_parquet(path)
resp = requests.get(
f"{DATA_SERVICE_URL}/api/candles",
params={"symbol": symbol, "tf": tf, "limit": limit},
timeout=60,
)
resp.raise_for_status()
df = _normalize(resp.json())
CACHE_DIR.mkdir(parents=True, exist_ok=True)
df.to_parquet(path, index=False)
return df
+141
View File
@@ -0,0 +1,141 @@
"""快速三类买卖点:不等笔确认,突破回抽当根即入场。
引擎的 B3/S3 要等 pullback_bi.sure_time(回拉笔被确认),滞后 9~10 根,
此时价格已从回抽低点反弹完毕,入场价被吃掉。
但三买的形态条件本身是实时可判的:
中枢已成 -> 收盘突破 zg -> 回抽最低不跌回中枢(low >= zg) -> 重新上行
最后一步发生的当根就能下单,滞后 1~2 根。
全部判定只使用当根及之前的数据,无未来函数。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
def find_fast_bsp3(
df: pd.DataFrame,
zones: pd.DataFrame,
scan: int = 200,
pullback_win: int = 30,
tol: float = 0.003,
max_per_zone: int = 1,
diag: dict | None = None,
) -> pd.DataFrame:
"""扫描每个中枢,找突破后回抽不回中枢的入场点。
zones 需含 zg / zd / available_ts,且 available_ts 已是可用时刻。
max_per_zone > 1 时,同一中枢在首次入场后继续往后找二次、三次突破回抽,
用来检验「趋势里同一中枢反复给机会」是否值得做。
返回列:
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
# 回抽后重新顺势:收盘创出前一根之上(三买)/ 之下(三卖)
if touched and pb_idx is not None:
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 or pb_ext is None:
if occ == 1:
note("×突破后跌回中枢" if fell_back
else "×回抽未触及边界" if not touched
else "×触及边界但未转强")
# 这次突破没走成,从突破点之后继续找下一次
cursor = bo_idx + 1
continue
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
return pd.DataFrame(rows)
+159
View File
@@ -0,0 +1,159 @@
"""分型级信号:绕开笔/线段/中枢的确认链,直接用分型 + 背驰做预设转折点。
动机:笔确认滞后 9~10 根、线段 101~121 根、买卖点 16~21 根,全部超过 alpha 半衰期(4~6 根)。
而分型只需右侧 KLC 完成即可确认,滞后通常 1~3 根,是唯一来得及的结构。
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from chanlun import TF_DF
from chanlun.core.ChanEnum import Chan_FX_TYPE
@dataclass
class FxSignal:
"""一个分型转折信号。confirm_idx 是实时可交易时刻。"""
direction: int # +1 底分型(潜在买) / -1 顶分型(潜在卖)
fx_idx: int # 分型极值所在K线
confirm_idx: int # 右侧KLC完成、分型可被确认的K线
lag: int # confirm_idx - fx_idx
price: float # 分型极值价
confirm_price: float # 确认时刻收盘价
seg_macd_area: float # 本段(前一反向分型 -> 本分型)的 MACD 面积
prev_seg_macd_area: float # 前一个同向段的 MACD 面积
prev_extreme: float # 前一个同向分型的极值价
is_divergence: bool # 是否背驰:价格创新极值但力度衰减
ratio: float # 面积比 本段/前段,越小背驰越强
def extract_fx_signals(chan: TF_DF, df: pd.DataFrame) -> list[FxSignal]:
"""从已建好的缠论结构里抽取分型信号,并就地算好背驰。"""
idx_of = {t: i for i, t in enumerate(df["date"].dt.strftime("%Y-%m-%d %H:%M:%S"))}
n = len(df)
closes = df["close"].to_numpy(dtype=float)
macdhist = (
df["macdhist"].to_numpy(dtype=float)
if "macdhist" in df.columns
else np.zeros(n)
)
if "macdhist" not in df.columns and hasattr(chan, "dataframe"):
if "macdhist" in chan.dataframe.columns:
macdhist = chan.dataframe["macdhist"].to_numpy(dtype=float)
# 按时间收集已成型的分型
raw = []
for klc in chan.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 = str(klc.end_time)
c_key = str(klc.next.end_klu.time)
if e_key not in idx_of or c_key not in idx_of:
continue
fx_idx = idx_of[e_key]
confirm_idx = idx_of[c_key]
if confirm_idx <= fx_idx:
continue
d = 1 if klc.fx == Chan_FX_TYPE.BOTTOM else -1
raw.append({
"d": d,
"fx_idx": fx_idx,
"confirm_idx": confirm_idx,
"price": float(klc.low if d == 1 else klc.high),
})
raw.sort(key=lambda r: r["fx_idx"])
def area(lo: int, hi: int, sign: int) -> float:
"""区间内顺方向的 MACD 柱面积。sign=-1 取负柱(下跌段),+1 取正柱。"""
if hi <= lo:
return 0.0
seg = macdhist[lo : hi + 1]
vals = seg[seg < 0] if sign < 0 else seg[seg > 0]
return float(np.abs(vals).sum())
signals: list[FxSignal] = []
for i, r in enumerate(raw):
d = r["d"]
# 本段起点 = 上一个反向分型;前一同向段 = 再往前一组
prev_opp = None
prev_same = None
prev_opp2 = None
for j in range(i - 1, -1, -1):
if prev_opp is None and raw[j]["d"] == -d:
prev_opp = raw[j]
continue
if prev_opp is not None and prev_same is None and raw[j]["d"] == d:
prev_same = raw[j]
continue
if prev_same is not None and prev_opp2 is None and raw[j]["d"] == -d:
prev_opp2 = raw[j]
break
if prev_opp is None:
continue
sign = -1 if d == 1 else 1 # 底分型前是下跌段,取负柱
cur_area = area(prev_opp["fx_idx"], r["fx_idx"], sign)
prev_area = (
area(prev_opp2["fx_idx"], prev_same["fx_idx"], sign)
if (prev_same is not None and prev_opp2 is not None)
else 0.0
)
# 背驰:价格创新极值(底更低 / 顶更高)但力度反而衰减
new_extreme = False
prev_extreme = np.nan
if prev_same is not None:
prev_extreme = prev_same["price"]
new_extreme = (
r["price"] <= prev_extreme if d == 1 else r["price"] >= prev_extreme
)
ratio = cur_area / prev_area if prev_area > 0 else np.nan
is_div = bool(new_extreme and prev_area > 0 and cur_area < prev_area)
signals.append(
FxSignal(
direction=d,
fx_idx=r["fx_idx"],
confirm_idx=r["confirm_idx"],
lag=r["confirm_idx"] - r["fx_idx"],
price=r["price"],
confirm_price=float(closes[r["confirm_idx"]]),
seg_macd_area=cur_area,
prev_seg_macd_area=prev_area,
prev_extreme=float(prev_extreme) if prev_extreme == prev_extreme else np.nan,
is_divergence=is_div,
ratio=float(ratio) if ratio == ratio else np.nan,
)
)
return signals
def signals_to_frame(signals: list[FxSignal]) -> pd.DataFrame:
return pd.DataFrame([s.__dict__ for s in signals])
def add_forward_returns(
sig: pd.DataFrame, df: pd.DataFrame, horizons=(3, 5, 10, 20, 40)
) -> pd.DataFrame:
"""以 confirm_idx 收盘价入场的方向调整收益。"""
closes = df["close"].to_numpy(dtype=float)
n = len(df)
out = sig.copy()
for h in horizons:
vals = []
for i, d in zip(out["confirm_idx"], out["direction"]):
j = int(i) + h
vals.append(d * (closes[j] - closes[int(i)]) / closes[int(i)] if j < n else np.nan)
out[f"ret_{h}"] = vals
return out
+88
View File
@@ -0,0 +1,88 @@
"""真正的区间套:大级别分型定位 + 小级别三类买卖点入场。
结构(缠论正统):
1. 大级别(1h / 4h)出顶底分型 —— 确认滞后仅 1 根,负责「预设转折点」与方向
2. 小级别(15m)在该位置形成中枢并被突破
3. 突破后回抽不回中枢 -> 15m 的第三类买卖点 —— 负责精确入场
关键:中枢与三买都在小级别,大级别只出分型。
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
def htf_fx_timeline(sig_htf: pd.DataFrame, df_htf: pd.DataFrame) -> pd.DataFrame:
"""把大级别分型压成一条按确认时间排序的时间线。
confirm_ts 是该分型最早可被使用的时刻,用它做对齐可避免未来函数。
注意 timestamp 是K线开盘时刻,而分型要等这根K线收盘才算数,
所以整体后移一个大级别周期;否则小级别会提前一整根大级别K线拿到信号。
"""
ts = df_htf["timestamp"].to_numpy()
period = int(np.median(np.diff(ts))) if len(ts) > 1 else 0
out = pd.DataFrame({
"confirm_ts": ts[sig_htf["confirm_idx"].to_numpy().astype(int)] + period,
"fx_ts": ts[sig_htf["fx_idx"].to_numpy().astype(int)],
"direction": sig_htf["direction"].to_numpy(),
"price": sig_htf["price"].to_numpy(),
"is_divergence": sig_htf["is_divergence"].to_numpy(),
"ratio": sig_htf["ratio"].to_numpy(),
})
return out.sort_values("confirm_ts").reset_index(drop=True)
def attach_htf_context(
ev: pd.DataFrame, df_ltf: pd.DataFrame, tl: pd.DataFrame, prefix: str
) -> pd.DataFrame:
"""给每个小级别买卖点挂上「入场时刻之前最近的大级别分型」。
ev 需含 entry_idx。产出列:
{prefix}_dir 最近大级别分型方向(+1 底 / -1 顶)
{prefix}_age_bars 距该分型确认过了多少根小级别K线
{prefix}_agree 大级别分型方向与本信号方向是否一致
{prefix}_div 该大级别分型是否背驰
{prefix}_dist 入场价相对该分型极值的距离(相对值)
"""
out = ev.copy()
if tl.empty or ev.empty:
for c in ("dir", "age_bars", "agree", "div", "dist"):
out[f"{prefix}_{c}"] = np.nan
return out
ts_ltf = df_ltf["timestamp"].to_numpy()
close = df_ltf["close"].to_numpy(dtype=float)
entry_idx = ev["entry_idx"].to_numpy().astype(int)
entry_ts = ts_ltf[entry_idx]
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)
fx_ts = tl["confirm_ts"].to_numpy()[k_safe]
fx_px = tl["price"].to_numpy()[k_safe].astype(float)
fx_div = tl["is_divergence"].to_numpy()[k_safe].astype(float)
# 用小级别K线间隔把时间差换算成根数
step = np.median(np.diff(ts_ltf)) if len(ts_ltf) > 1 else 1
age = (entry_ts - fx_ts) / max(step, 1)
out[f"{prefix}_dir"] = np.where(valid, fx_dir, np.nan)
out[f"{prefix}_age_bars"] = np.where(valid, age, np.nan)
# 分型确认时刻当作该分型的唯一标识,用来判断多个小级别信号是否同源
out[f"{prefix}_fx_ts"] = np.where(valid, fx_ts, np.nan)
out[f"{prefix}_div"] = np.where(valid, fx_div, np.nan)
out[f"{prefix}_dist"] = np.where(
valid, (close[entry_idx] - fx_px) / np.clip(np.abs(fx_px), 1e-9, None), np.nan
)
out[f"{prefix}_agree"] = np.where(
valid, (fx_dir == out["direction"].to_numpy()).astype(float), np.nan
)
return out
+106
View File
@@ -0,0 +1,106 @@
"""区间套:用大级别中枢边界给小级别信号定位。
思路(缠论正统做法):
大级别中枢的 zg/zd 是支撑压力位 -> 小级别在这些位置附近出现的分型+背驰,
才是高质量的预设转折点。位置本身就是过滤器,不需要等笔/中枢确认。
严格性:只使用在信号时刻之前就已经确认(sure_time 已过)的大级别中枢,
避免用到当时尚不可知的结构。
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
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
def annotate_position(
sig: pd.DataFrame, df_ltf: pd.DataFrame, zones: pd.DataFrame, tol: float = 0.01
) -> pd.DataFrame:
"""给每个小级别信号标注它相对大级别中枢的位置。
tol —— 判定"贴近"边界的相对距离阈值(默认 1%)。
"""
if zones.empty:
out = sig.copy()
for c in ("near_support", "near_resistance", "inside_zone", "outside_zone", "zone_pos"):
out[c] = False if c != "zone_pos" else np.nan
return out
ts = df_ltf["timestamp"].to_numpy()
zone_avail = zones["available_ts"].to_numpy()
zg = zones["zg"].to_numpy()
zd = zones["zd"].to_numpy()
near_sup, near_res, inside, outside, zpos = [], [], [], [], []
for _, r in sig.iterrows():
i = int(r["confirm_idx"])
now_ts = ts[i]
price = float(r["price"])
# 最近一个在此刻之前已可用的大级别中枢
k = np.searchsorted(zone_avail, now_ts, side="right") - 1
if k < 0:
near_sup.append(False); near_res.append(False)
inside.append(False); outside.append(False); zpos.append(np.nan)
continue
z_g, z_d = zg[k], zd[k]
width = z_g - z_d
near_sup.append(abs(price - z_d) / price <= tol)
near_res.append(abs(price - z_g) / price <= tol)
inside.append(z_d <= price <= z_g)
outside.append(price > z_g or price < z_d)
zpos.append((price - z_d) / width if width > 0 else np.nan)
out = sig.copy()
out["near_support"] = near_sup
out["near_resistance"] = near_res
out["inside_zone"] = inside
out["outside_zone"] = outside
out["zone_pos"] = zpos # 0=中枢下沿, 1=中枢上沿
# 顺位:买信号贴支撑 / 卖信号贴压力,才算"位置正确"
out["position_ok"] = np.where(
out["direction"] == 1, out["near_support"], out["near_resistance"]
)
return out
+185
View File
@@ -0,0 +1,185 @@
"""Walk-forward 重放:用滑动窗口逐步重算缠论,检验买卖点在实时环境下是否稳定。
要回答三个问题:
1. 幻影率——实时曾报出、但在完整历史上并不存在的信号占多少?
2. 撤销率——报出后又被结构演化抹掉的信号占多少?
3. 真实滞后——信号第一次可被观测到的时刻,比 sure_time 晚多少?
"""
from __future__ import annotations
import os
import pickle
import sys
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass
from pathlib import Path
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from chanlun.core.ChanEnum import Chan_BSP_DIR
# 子进程共享的只读数据,避免每次任务重复 pickle 整个 DataFrame
_G: dict = {}
BspKey = tuple[str, int, str] # (类型, 方向, 分型时间)
def _init_worker(df: pd.DataFrame, tf: str, zs_source: str) -> None:
_G["df"] = df
_G["tf"] = tf
_G["zs_source"] = zs_source
def _bsp_keys_for_window(args: tuple[int, int]) -> tuple[int, list[BspKey]]:
"""在 df[start:end] 这个窗口上重算缠论,返回该时点可观测到的买卖点集合。"""
from chanlun import TF_DF # 延迟导入,避免父进程重复加载
start, end = args
df = _G["df"].iloc[start:end].reset_index(drop=True)
try:
chan = TF_DF(df, 1, _G["tf"])
if _G["zs_source"] == "pure":
bi_zs_list = chan.cal_bi_zs_list_pure(chan.bi_list)
else:
bi_zs_list = chan.cal_bi_zs(chan.seg_list)
bsp_list = chan.find_all_bsp(chan.bi_list, bi_zs_list) if bi_zs_list else []
except Exception:
return end - 1, []
keys = [
(
str(b.type).replace("Chan_BSP_TYPE.", ""),
1 if b.dir == Chan_BSP_DIR.BUY else -1,
str(b.end_time),
)
for b in bsp_list
if b.is_sure and b.sure_time is not None
]
return end - 1, keys
@dataclass
class ReplayResult:
observations: dict[int, set[BspKey]] # 每个重算时点 -> 当时可见的买卖点集合
checkpoints: list[int]
window: int
step: int
def replay(
df: pd.DataFrame,
tf: str,
window: int = 3000,
step: int = 6,
workers: int | None = None,
cache_key: str | None = None,
zs_source: str = "seg",
) -> ReplayResult:
"""滑动窗口重放。窗口右端每 step 根前进一次,每次完整重算一遍缠论。
重放很贵(分钟级),cache_key 非空时把结果落盘复用。
"""
n = len(df)
if n <= window:
raise ValueError(f"数据长度 {n} 不足以支撑窗口 {window}")
cache_path = None
if cache_key:
cache_dir = Path(__file__).resolve().parents[1] / ".cache"
cache_dir.mkdir(parents=True, exist_ok=True)
cache_path = cache_dir / f"replay__{cache_key}__{zs_source}__w{window}_s{step}_n{n}.pkl"
if cache_path.exists():
with cache_path.open("rb") as fh:
obs = pickle.load(fh)
print(f" [cache] 命中重放缓存 {cache_path.name}")
return ReplayResult(observations=obs, checkpoints=sorted(obs), window=window, step=step)
tasks = [(end - window, end) for end in range(window, n + 1, step)]
workers = workers or max(1, (os.cpu_count() or 4) - 1)
observations: dict[int, set[BspKey]] = {}
with ProcessPoolExecutor(
max_workers=workers, initializer=_init_worker, initargs=(df, tf, zs_source)
) as pool:
for i, (bar_idx, keys) in enumerate(pool.map(_bsp_keys_for_window, tasks, chunksize=8)):
observations[bar_idx] = set(keys)
if (i + 1) % 500 == 0:
print(f" ...{i + 1}/{len(tasks)} 窗口", flush=True)
if cache_path is not None:
with cache_path.open("wb") as fh:
pickle.dump(observations, fh)
return ReplayResult(observations=observations, checkpoints=sorted(observations), window=window, step=step)
def analyze_stability(
result: ReplayResult,
final_keys: set[BspKey],
df: pd.DataFrame,
window: int,
maturity_bars: int = 300,
edge_buffer: int = 500,
) -> pd.DataFrame:
"""把重放结果整理成每个信号的生命周期:首次出现、最后可见、是否被撤销。
两处偏差必须剔除,否则统计会失真:
- 左截断:分型早于第一个窗口起点的信号,其 first_seen 是假的,标记 truncated。
- 右截断:临近重放末尾才出现的信号还没经历足够演化,谈不上"没被撤销",标记 immature。
"""
checkpoints = result.checkpoints
times = df["date"].dt.strftime("%Y-%m-%d %H:%M:%S").to_numpy()
idx_of = {t: i for i, t in enumerate(times)}
first_seen: dict[BspKey, int] = {}
last_seen: dict[BspKey, int] = {}
seen_count: dict[BspKey, int] = {}
for bar_idx in checkpoints:
for key in result.observations[bar_idx]:
first_seen.setdefault(key, bar_idx)
last_seen[key] = bar_idx
seen_count[key] = seen_count.get(key, 0) + 1
last_cp = checkpoints[-1]
rows = []
for key, first in first_seen.items():
bsp_type, direction, fx_time = key
fx_idx = idx_of.get(fx_time)
# 窗口是滑动的,信号的分型一旦滑出窗口左端就必然不可见。
# 只在「信号仍被窗口覆盖」的检查点上评价持续性,否则会把正常的滑出误判成撤销。
# 另需 edge_buffer:贴着窗口左端时缠论缺少前置K线构造包含关系,信号会因边界效应消失。
if fx_idx is None:
in_scope = [cp for cp in checkpoints if cp >= first]
else:
in_scope = [
cp for cp in checkpoints
if cp >= first and (cp - window) < (fx_idx - edge_buffer)
]
scope_end = in_scope[-1] if in_scope else first
visible_in_scope = sum(1 for cp in in_scope if key in result.observations[cp])
rows.append(
{
"bsp_type": bsp_type,
"direction": direction,
"fx_time": fx_time,
"fx_idx": fx_idx,
"first_seen_idx": first,
"first_seen_time": times[first],
"last_seen_idx": last_seen[key],
"observed_lag": (first - fx_idx) if fx_idx is not None else None,
"scope_checkpoints": len(in_scope),
"persist_ratio": visible_in_scope / len(in_scope) if in_scope else 0.0,
"in_final": key in final_keys,
# 在「仍被窗口覆盖」的最后一个检查点上是否还活着
"alive_at_scope_end": key in result.observations.get(scope_end, set()),
# 分型发生在首个窗口完全覆盖之后,first_seen 才是真实的
"truncated": fx_idx is None or fx_idx < window,
# 覆盖范围太短则谈不上"没被撤销"
"immature": len(in_scope) < maturity_bars // max(result.step, 1),
}
)
return pd.DataFrame(rows).sort_values("first_seen_idx").reset_index(drop=True)