起因是用户看图指出「止盈没做好」,查下来 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>
241 lines
9.3 KiB
Python
241 lines
9.3 KiB
Python
"""Step 42:1m 单独定出场(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. 初始 SL:R 倍数口径(唯一可比)##########")
|
||
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()
|