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>
This commit is contained in:
jackyu66git
2026-08-28 00:05:56 +08:00
co-authored by Cursor
parent 9b72173285
commit 32c18f0201
8 changed files with 2415 additions and 29 deletions
+240
View File
@@ -0,0 +1,240 @@
"""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. 减仓后剩余半仓的止损位(样本外,分批→8ATR,48根)##########")
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()