refactor: 精简仓库为 chanlun 核心与 web 分析,移除威科夫与遗留模块
删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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}%"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""研究用数据层:本地历史数据优先,回落到远端数据服务。
|
||||
|
||||
远端服务的小周期只保留 90 天(派生自 1m),做区间套时样本严重不足。
|
||||
本项目 data/ 下是完整下载的 BTC/ETH/SOL 全周期数据(1m~1w,2172~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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user