Files
Chan/research/step42_exit_tp_1m.py
T
jackyu66gitandCursor 32c18f0201 research: 1m 出场口径重定、费率修正,与影子测量的判据常数
起因是用户看图指出「止盈没做好」,查下来 TP=3.0 确实把右尾截早了,
而且 1m 不该沿用 5m/15m/30m 的参数——成本固定在 bp、目标随 ATR 缩放,
1m 的 3 ATR 只有 0.39% 而 30m 是 1.87%,成本占比差 5 倍。

step41(5m/15m/30m)与 step42(1m)跑同一张全网格:
SL × TP × MAX_BARS × 分批(3 ATR 减半 → 剩余目标 × 剩余半仓止损位)。

- 1m 最优 SL2 / 3ATR 减半 / 剩余止损保持 2.0 / 目标 8ATR / 48 根,
  样本外 8/8 币、7/7 年全面提升,均R/R夏普/回撤/剔10%PF 四项全赢
- 分批要做,但**减仓后不要动止损**。止损位 0/0.5/1/1.5/2 ATR 严格单调,
  越紧越差,三组初始 SL 全一致。保本损是全表最差的一档
- SL=1.0 在 1m 上是废的:剔10%PF 0.78~0.99、中位收益 −0.122%

费率此前写的 taker 3bp / maker 1bp 隐含「原始 taker 6bp」的错误前提,
实际是原始 taker 0.040% / maker 0.016%、返 50% 后 2.0 / 0.8bp。方向是保守的,
所以首轮跑出来的数字全部偏低。exit_model 已改,费率只在分析阶段套用,
不必重跑模拟。改完 1m 的均R +8%,5m/15m/30m 只动 2%——费率只对 1m 有杠杆。

顺带查证了用户的一个假设:余量逐年递减是不是跟波动率有关。成立,而且
r = +0.989。毛/ATR 七年在 2.26~2.67 之间没有趋势,衰减的是 ATR 本身
(2021 的 22.1bp 压到 2026 的 8.8bp)。**是波动率压缩,不是 alpha 衰减。**

由此引出 ATR 门控:低 ATR 桶的毛 R 其实最高(1.16 vs 高 ATR 桶的 0.94),
断崖只在扣费之后出现。所以阈值是**费率的函数**(约 5 + 1.1×taker费),
不是市场常数。当前费率下 ≥8bp,在 5m/15m/30m 上几乎不触发,可作全局规则。

lib/shadow_budget.py 放影子测量要对照的常数:逐币预算、门控阈值、
腿→maker/taker 映射、lag 阈值、判据。记录与报表归 research/live/,
分工的理由是这些数会变——今天预算就动了四次。

out/*.feather 转为 ignore:70MB+ 且重跑可得,摘要都在 HANDOFF。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 00:05:56 +08:00

241 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Step 421m 单独定出场(SL × TP × MAX_BARS × 分批 + 真实费率模型)。
用户指出的问题:`SL1.5/TP3.0/MAXB48` 是在 5m/15m/30m 上定的,1m 不该混用。
查证成立,而且原因比成本更根本——**1m 以 ATR 计的右尾是其他级别的两倍**
MFE 中位 5.39 ATR vs 3.94~4.29),因为 1m 的 ATR 量的是分钟级噪声,
信号一旦成立抓到的却是几小时级别的趋势。所以「TP=3 ATR」在两个级别上
根本不是一回事,这是量纲问题不是调参问题。
费率按出场原因分别计(入场 taker / 止盈 maker / 止损超时 taker),
实现在 lib/exit_model.py,与 step41 共用。
配对按 §1.5:1m 的大级别是 5m。
⚠️ 1m 全量 366 万根,单币 TF_DF 约 370s / 峰值 24.5GB,最多开 3 并行。
"""
from __future__ import annotations
import argparse
import os
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(v, "1")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
pd.set_option("display.width", 400)
LTF, HTF = "1m", "5m"
SLS = [1.0, 1.5, 2.0]
TPS = [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 12.0]
MAXBS = [48, 96]
RUNNERS = [5.0, 6.0, 8.0, None]
SCALE_AT = 3.0
# 减仓后剩余半仓的止损位(开仓价下方几个 ATR)。0 = 保本损,等于初始 SL 即不动
RUNNER_STOPS = [0.0, 0.5, 1.0, 1.5, 2.0]
IS_SYMS = ["BTC", "ETH", "SOL"]
OOS_SYMS = ["BNB", "XRP", "DOGE", "ADA", "AVAX", "LINK", "LTC", "TRX"]
def cfgs() -> list[tuple[str, str, float]]:
"""所有配置的 (列名, 可读标签, 初始 SL)。"""
from lib.exit_model import cfg_name
out = []
for sl in SLS:
for b in MAXBS:
for t in TPS:
out.append((cfg_name(sl, t, b), f"SL{sl:g} 整仓TP{t:g} {b}根", sl))
for rn in RUNNERS:
tgt = f"{rn:g}ATR" if rn else "不设目标"
for k in RUNNER_STOPS:
kk = "保本" if k == 0 else f"留损{k:g}"
out.append((cfg_name(sl, rn, b, k),
f"SL{sl:g} 分批→{tgt} {kk} {b}根", sl))
return out
def run_one(task: tuple) -> dict | None:
import warnings as _w
_w.filterwarnings("ignore")
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
from chanlun import TF_DF
from lib.data import fetch_ohlcv
from lib.exit_model import walk_exits
from lib.fast_bsp3 import find_fast_bsp3
from lib.fx_signal import extract_fx_signals, signals_to_frame
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
from lib.nested_level import build_htf_zones
sym, group = task
pair = f"{sym}/USDT:USDT"
try:
df_l = fetch_ohlcv(pair, LTF, 10 ** 9)
if df_l is None or len(df_l) < 3000:
return {"task": sym, "error": "1m 数据不足"}
chan_l = TF_DF(df_l, 1, LTF)
cdf = chan_l.dataframe
del df_l
zones = build_htf_zones(cdf, LTF, chan=chan_l).reset_index(drop=True)
if zones.empty:
return {"task": sym, "error": "无中枢"}
z = zones.copy()
pg, pdn = z["zg"].shift(), z["zd"].shift()
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
z["zone_i"] = np.arange(len(z))
df_h = fetch_ohlcv(pair, HTF, 10 ** 9)
chan_h = TF_DF(df_h, 1, HTF)
tl = htf_fx_timeline(
signals_to_frame(extract_fx_signals(chan_h, chan_h.dataframe)), chan_h.dataframe)
del df_h, chan_h
sig = find_fast_bsp3(cdf, zones)
if sig.empty:
return {"task": sym, "error": "无信号"}
sig = sig.merge(z[["zone_i", "z_above", "z_below"]], on="zone_i", how="left")
sig = attach_htf_context(sig, cdf, tl, "h1")
push = np.where(sig["direction"] == 1, sig["z_above"], sig["z_below"])
keep = (sig["h1_agree"] == 1) & pd.Series(push, index=sig.index).fillna(False).astype(bool)
sig = sig[keep]
if len(sig) < 25:
return {"task": sym, "error": f"过滤后仅 {len(sig)} 笔"}
r = walk_exits(cdf, sig, SLS, TPS, MAXBS, SCALE_AT, RUNNERS, RUNNER_STOPS)
r["symbol"], r["group"] = sym, group
r["date"] = cdf["date"].to_numpy()[r["sig_idx"].to_numpy()]
return {"task": sym, "rows": r}
except Exception as e:
return {"task": sym, "error": repr(e)[:200]}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="all")
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
from lib.exit_model import (all_taker_cost, cfg_name, cost_of, flat_cost,
rstat, stat)
cache = HERE / "out" / "step42_exit_tp_1m.feather"
if args.reuse and cache.exists():
allr = pd.read_feather(cache)
print(f"[复用] {len(allr)}\n")
else:
if args.symbols == "all":
tasks = [(s, "样本内") for s in IS_SYMS] + [(s, "样本外") for s in OOS_SYMS]
elif args.symbols == "is":
tasks = [(s, "样本内") for s in IS_SYMS]
else:
picked = [s.strip() for s in args.symbols.split(",")]
tasks = [(s, "样本内" if s in IS_SYMS else "样本外") for s in picked]
print(f"[1m 出场全扫] {len(tasks)} 个币 × {len(cfgs())} 个配置,"
f"{args.workers} 并行(单币约 6.5 分钟 / 24GB)\n", flush=True)
res = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futs = {ex.submit(run_one, t): t for t in tasks}
for i, f in enumerate(as_completed(futs), 1):
r = f.result()
if not r or "error" in r:
print(f" [{i}/{len(tasks)}] 跳过 {(r or {}).get('task', '')} "
f"{(r or {}).get('error', '')}", flush=True)
continue
res.append(r["rows"])
print(f" [{i}/{len(tasks)}] {r['task']} n={len(r['rows'])}", flush=True)
if not res:
return
allr = pd.concat(res, ignore_index=True)
allr.to_feather(cache)
oos = allr[allr["group"] == "样本外"]
ins = allr[allr["group"] == "样本内"]
base = "s1.5_tp3_m48"
print("=" * 170)
print(f"1m 出场全扫 样本内 {len(ins)} 笔 / 样本外 {len(oos)} 笔")
print("\n########## 1. 费率模型的影响(样本外,SL1.5/TP3/48根)##########")
gr = oos[f"{base}_g"].to_numpy()
rs, sc = oos[f"{base}_r"].to_numpy(), oos[f"{base}_c"].to_numpy()
for fn, name in ((all_taker_cost, "旧 全taker双边"),
(cost_of, "新 入场taker+止盈maker"),
(flat_cost, "研究口径 固定5bp")):
print(f" {name:26s} 平均成本 {fn(rs, sc).mean() * 10000:5.2f}bp "
f"净均收益 {(gr - fn(rs, sc)).mean() * 100:+.3f}%")
print(f" 止盈成交占比 {(rs == 0).mean() * 100:.0f}%(只有这部分是 maker")
print("\n########## 2. 全网格排名(样本外,按剔10%PF##########")
rank = [r for r in (stat(oos, c, lab) for c, lab, _ in cfgs()) if r]
rk = pd.DataFrame(rank)
rk["_k"] = rk["剔10%PF"].astype(float)
print(rk.sort_values("_k", ascending=False).drop(columns="_k").head(15).to_string(index=False))
print("\n########## 3. 减仓后剩余半仓的止损位(样本外,分批→8ATR48根)##########")
print(" 0 = 保本损;等于初始 SL 即不动;更大 = 减仓后主动放宽")
for sl in SLS:
rows = []
for k in RUNNER_STOPS:
kk = "保本损" if k == 0 else f"留损{k:g}ATR"
rows.append(stat(oos, cfg_name(sl, 8.0, 48, k), f"初始SL{sl:g}{kk}"))
rows = [r for r in rows if r]
if rows:
print(pd.DataFrame(rows).to_string(index=False))
print()
print("########## 4. 初始 SLR 倍数口径(唯一可比)##########")
for name, frame in (("样本外", oos), ("样本内", ins)):
rows = []
for sl in SLS:
rows.append(rstat(frame, cfg_name(sl, 3.0, 48), f"SL{sl:g} 整仓TP3/48", sl))
rows.append(rstat(frame, cfg_name(sl, 8.0, 48, sl),
f"SL{sl:g} 分批→8ATR 留损{sl:g}/48", sl))
print(f" 【{name}】")
print(pd.DataFrame(rows).to_string(index=False))
print()
print("########## 5. 现用 vs 最优:样本内外对照 ##########")
best_lab = rk.sort_values("_k", ascending=False).iloc[0]["口径"]
lut = {lab: (c, sl) for c, lab, sl in cfgs()}
best_cfg, best_sl = lut[best_lab]
rows = []
for name, frame in (("样本内", ins), ("样本外", oos)):
rows += [stat(frame, base, f"{name} 现用 SL1.5/TP3/48根"),
stat(frame, best_cfg, f"{name} 最优 {best_lab}")]
print(pd.DataFrame([r for r in rows if r]).to_string(index=False))
print("\n 同一对比的 R 倍数口径:")
rows = []
for name, frame in (("样本内", ins), ("样本外", oos)):
rows += [rstat(frame, base, f"{name} 现用 SL1.5/TP3/48根", 1.5),
rstat(frame, best_cfg, f"{name} 最优 {best_lab}", best_sl)]
print(pd.DataFrame(rows).to_string(index=False))
print(f"\n########## 6. {best_lab} 的逐币与分年稳定性(样本外)##########")
f = oos.assign(year=pd.to_datetime(oos["date"]).dt.year)
for key, name in (("symbol", "币"), ("year", "年")):
rows = []
for k, g in f.groupby(key):
a, b = stat(g, base, str(k)), stat(g, best_cfg, str(k))
if a and b:
rows.append({name: k, "笔数": a["笔数"],
"现用 净收益": a["净均收益"], "最优 净收益": b["净均收益"],
"现用 PF": a["PF"], "最优 PF": b["PF"],
"现用 剔10%": a["剔10%PF"], "最优 剔10%": b["剔10%PF"],
"现用 余量bp": a["滑点余量bp"], "最优 余量bp": b["滑点余量bp"]})
print(pd.DataFrame(rows).to_string(index=False))
print()
if __name__ == "__main__":
main()