Merge remote-tracking branch 'origin/chan' into chan

This commit is contained in:
jackyu66git
2026-08-31 23:03:28 +08:00
8 changed files with 303 additions and 16 deletions
+32 -5
View File
@@ -155,6 +155,25 @@ class Bitget:
"marginCoin": MARGIN_COIN,
"marginMode": mode})
async def set_position_mode(self, mode: str = "one_way_mode") -> dict:
"""单向 / 双向持仓。**按 productType 生效,不是按 symbol。**
必须显式设,因为它决定下单体的语法,两者不匹配会被整体拒单:
单向:side=buy/sell**不带** tradeSide;平仓用 reduceOnly=YES
双向:side + tradeSide=open/closereduceOnly 在这个模式下无效
实盘上曾因为带着 tradeSide 打到单向账户,连续 8 次下单全被 40774 拒掉
(4 个信号 × 2 条腿),而链路其余部分完全正常。
我们永不同时持有两个方向,所以单向是对的模式;且 reduceOnly 只在单向
下可用,而出场腿依赖它防止反手开出反向仓。
交易所侧有持仓或挂单时切换会失败——所以调用点放在 reconcile 之后。
"""
return await self._req("POST", "/api/v2/mix/account/set-position-mode",
body={"productType": PRODUCT, "posMode": mode})
async def entry_with_stop(self, symbol: str, side: str, size: str,
stop_px: str, client_oid: str) -> dict:
"""市价入场,**同时**把止损挂到服务端。
@@ -162,10 +181,12 @@ class Bitget:
`presetStopLossPrice` 触发后按市价执行,这正是成本模型要的(止损是
taker)。`clientOid` 给交易所级幂等——重发同一个 oid 会被拒,比本地
去重可靠,因为「已发出但没收到回复」这种情况本地判不了。
**不带 `tradeSide`**:账户是单向持仓,带了会被 40774 整体拒单。
"""
body = {"symbol": symbol, "productType": PRODUCT,
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
"size": size, "side": side, "tradeSide": "open",
"size": size, "side": side,
"orderType": "market", "clientOid": client_oid,
"presetStopLossPrice": stop_px}
if self.dry:
@@ -180,11 +201,14 @@ class Bitget:
`side` 传的是**平仓方向**(多头止盈是 sell)。`post_only` 保证是 maker
成本模型里止盈那 60% 按 maker 费率计且不吃滑点,用 taker 会破坏预算。
`reduceOnly` 防止在单向模式下反手开出一个反向仓。
单向持仓下平仓的写法是 `side` 取反 + `reduceOnly=YES`**不带**
`tradeSide`。`reduceOnly` 也正好只在单向模式下有效,它防止反手开出
一个反向仓。
"""
body = {"symbol": symbol, "productType": PRODUCT,
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
"size": size, "side": side, "tradeSide": "close",
"size": size, "side": side,
"orderType": "limit", "price": px, "force": "post_only",
"reduceOnly": "YES", "clientOid": client_oid}
if self.dry:
@@ -195,11 +219,14 @@ class Bitget:
async def close_market(self, symbol: str, hold_side: str,
size: str, client_oid: str) -> dict:
"""市价平(超时腿与对账用)。"""
"""市价平(超时腿与对账用)。
同 tp_limit:单向持仓下是 `side` 取反 + `reduceOnly`,不带 `tradeSide`。
"""
side = "sell" if hold_side == "long" else "buy"
body = {"symbol": symbol, "productType": PRODUCT,
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
"size": size, "side": side, "tradeSide": "close",
"size": size, "side": side,
"orderType": "market", "reduceOnly": "YES",
"clientOid": client_oid}
if self.dry:
+6 -2
View File
@@ -113,8 +113,10 @@ journalctl -u chan-live-ship -f # 搬运日志
**Telegram**`live.env` 里填 `TG_TOKEN` / `TG_CHAT` 就开。启动时会推一条
"执行器启动",兼作通道自检——配错了当场就知道,而不是等几小时后第一个真信号
来时才发现。推开仓、平仓(带已实现盈亏)、被硬约束挡住、报错、对账平仓、跨日
结算;不推信号过期跳过(常态)和心跳。约 30 条/天上限。
来时才发现。之后每小时一条在线(`TG_HB_MIN`,默认 60),带建仓数、当日盈亏
和搬运 ssh 新鲜度——执行器活着不代表上游还在投信号。推开仓、平仓(带已实现
盈亏)、被硬约束挡住、报错、对账平仓、跨日结算;不推信号过期跳过(常态)和
5 分钟日志心跳。约 55 条/天上限。
## 停机与回滚
@@ -140,6 +142,8 @@ cd /opt/chan && sudo git reset --hard <sha> && sudo systemctl restart chan-live-
| 启动即 `40018` / 签名错 | 出口 IP 不在白名单,或密钥抄错。`curl https://api.ipify.org` 对一下 |
| 搬运日志 `Permission denied (publickey)` | 第 3 步的 pubkey 没加到采集机 |
| 搬运在线但一直没信号 | 采集机没重启过(`signal_bus.emit` 没加载),或对端总线路径不对 |
| Telegram 在线报「读不到搬运」 | 搬运没起,或还是没落 `ship_alive.json` 的旧版本,两边一起重启 |
| Telegram 在线报「ssh 已断开」 | 采集机 ssh 断了,搬运在重连。看 `chan-live-ship` 日志 |
| 日志 `⛔ 时间倒流 Xs` | 两机时钟不同步,**staleness 闸已不可信**。查两边 `chronyc tracking` |
| 信号收到但都被跳过 | `age > LIVE_STALE_S`。看是搬运慢还是时钟偏;也可能是重连重放的旧信号(这种跳过是对的) |
| `systemctl status` 显示 start-limit-hit | 5 分钟内重启 5 次,systemd 停手了。先看 journal 找真因,再 `systemctl reset-failed` |
+6 -3
View File
@@ -59,10 +59,13 @@ LIVE_WATCH_S=10
# https://api.telegram.org/bot<TOKEN>/getUpdates 看 result[0].message.chat.id
#
# 留空则完全不推(不报错)。推的内容:开仓、平仓(带已实现盈亏)、被硬约束
# 挡住、报错、对账平仓、跨日结算、启动与停机。
# **不推**信号过期跳过(常态,搬运重连会重放旧信号)和心跳(日志里有)
# 量级约 30 条/天上限。
# 挡住、报错、对账平仓、跨日结算、启动与停机、整点在线
# **不推**信号过期跳过(常态,搬运重连会重放旧信号)和 5 分钟日志心跳。
# 量级约 55 条/天上限(含 24 条在线)
TG_TOKEN=
TG_CHAT=
# 前缀,用来和采集机推的手工信号区分开——两边可以共用同一个 bot 和对话
TG_TAG=实盘
# 整点在线的间隔(分钟)。0 关掉。60 = 每天 24 条,和成交推送量级相当。
# 这条必须带上游新鲜度:执行器活着不代表链路活着
TG_HB_MIN=60
+35
View File
@@ -67,10 +67,45 @@ fi
hr "最近成交"
if [[ -s "$STATE/live_trades.jsonl" ]]; then
tail -5 "$STATE/live_trades.jsonl" | sed 's/^/ /'
# 「下过单但一次都没建上」是明确的故障,而它的外在表现和"没信号"一样,
# 不主动判读就会白跑几小时。首日就是这么丢掉 4 个信号的
nf="$(grep -c '"ev": "entry_fail"' "$STATE/live_trades.jsonl" || true)"
nb="$(grep -c '"ev": "opened", "key": [^]]*\[{' "$STATE/live_trades.jsonl" || true)"
ne="$(grep -c '"ev": "entry"' "$STATE/live_trades.jsonl" || true)"
if [[ "${nf:-0}" -gt 0 ]]; then
echo
echo " ⛔ 有 $nf 次入场被拒(共尝试 $ne 个信号)"
echo " 最后一条错误:"
grep '"ev": "entry_fail"' "$STATE/live_trades.jsonl" | tail -1 \
| sed 's/^/ /'
echo " 40774 = 持仓模式不匹配 · 40762/40786 = 保证金不足"
fi
else
echo " 还没有成交记录"
fi
hr "搬运存活文件"
if [[ -f "$STATE/ship_alive.json" ]]; then
python3 - "$STATE/ship_alive.json" <<'PY'
import json, sys, time
d = json.load(open(sys.argv[1]))
age = time.time() - d.get("ts", 0)
up = d.get("up_s", 0)
conn = d.get("connected")
if age > 720:
state = f"⛔ 已停更 {age/60:.0f} 分钟,进程可能死了"
elif conn is False:
state = "⛔ ssh 已断开,正在重连"
elif conn is True:
state = f"ssh 在线 {up/60:.0f} 分钟"
else:
state = "文件是旧格式(没有 connected),重启搬运后才会有"
print(f" {state} · 重连 {d.get('n_reconnect', 0)} 次 · 新增 {d.get('n_new', 0)} 条")
PY
else
echo " 还没有 ship_alive.json(搬运没起来,或还是没落盘的旧版本)"
fi
hr "搬运心跳(最近 3 条)"
journalctl -u chan-live-ship -n 200 --no-pager 2>/dev/null \
| grep -F "[心跳]" | tail -3 | sed 's/^/ /' \
+90 -3
View File
@@ -109,6 +109,9 @@ STALE_S = float(os.environ.get("LIVE_STALE_S", "20"))
# 盯交易所侧出场的轮询间隔。10s 足够:出场后要做的只是记账与放开 MAX_OPEN
# 名额,不涉及下单时效。太密会白耗 API 配额
WATCH_S = float(os.environ.get("LIVE_WATCH_S", "10"))
# 整点在线推送的间隔(分钟),0 关掉。60 分钟 = 24 条/天,和成交推送量级相当
# 不会淹掉真事。调到 5 以下没意义:日志心跳就是 5 分钟一次
TG_HB_MIN = float(os.environ.get("TG_HB_MIN", "60"))
STATE = Path(os.environ.get("LIVE_STATE", LIVE_HOME / "state" / "live_state.json"))
TRADES = Path(os.environ.get("LIVE_TRADES", LIVE_HOME / "state" / "live_trades.jsonl"))
@@ -254,6 +257,13 @@ class Exec:
self.execs: dict = {} # key → 该笔的腿与超时时刻
self.offset = 0 # 已读到总线的哪一行
self.n_seen = self.n_took = self.n_skip = 0
# 建仓成功/失败分开计。只看 n_took 分不出"做了但下单被拒"——首日
# 那 5 小时里 n_took=4 而实际一笔都没建上
self.n_built = self.n_order_fail = 0
self.t0 = time.time()
# 置 0 让第一次 5 分钟心跳就推,不必等满一个周期:重启后最该尽早确认
# 的是「上游也通」,而这个只有在线那条带得出来
self.tg_hb_at = 0.0
# ── 启动 ──────────────────────────────────────────────────────
async def start(self) -> None:
@@ -335,6 +345,37 @@ class Exec:
"BITGET_PASSPHRASE(末项无 API_)。先跑 --dry-run。")
await self.setup_symbols()
await self.reconcile()
# 放在 reconcile 之后:有持仓或挂单时交易所不允许切换持仓模式,而
# reconcile 刚把仓位平干净
await self.setup_position_mode()
async def setup_position_mode(self) -> None:
"""把持仓模式钉成单向,并读回核对。
为什么必须显式设而不是假设:持仓模式决定下单体的语法,两者不匹配是
**整体拒单**,不是部分降级。实盘上就因为带着 `tradeSide`(双向语法)
打到单向账户,连续 8 次下单全被 40774 拒掉——4 个信号 × 2 条腿,而
投递链路那时完全正常(延后 0.0~0.2s、ssh 零重连)。
单向是我们要的:永不同时持有两个方向,且出场腿依赖 `reduceOnly`
而它只在单向模式下有效。
"""
try:
d = await self.api.set_position_mode("one_way_mode") or {}
except Exception as e: # noqa: BLE001
print(f" ⛔ 设持仓模式失败 {type(e).__name__}: {e}", flush=True)
print(" 若账户实际是双向持仓,下单会被 40774 全部拒掉。"
"先在 App 里平掉所有仓位与挂单,再切成单向", flush=True)
await tg.error("设持仓模式失败,下单可能被 40774 全拒", str(e))
return
got = str(d.get("posMode") or "")
if got and got != "one_way_mode":
print(f" ⛔ 持仓模式仍是 {got},下单体是单向语法,会被 40774 拒",
flush=True)
await tg.error(f"持仓模式是 {got},不是单向", "下单会被 40774 拒掉")
else:
print(f" 已设持仓模式 单向{'(已读回核对)' if got else ''}",
flush=True)
async def setup_symbols(self) -> None:
"""逐仓 + 杠杆。每次启动都设一遍,不假设交易所侧的状态。
@@ -543,7 +584,7 @@ class Exec:
hold = "long" if d > 0 else "short"
stop_px = self.snap(sym, entry - d * SL_ATR * a)
opened = []
opened, failed = [], []
for x in lg:
oid = oid_of(r["key"], x["tag"])
try:
@@ -552,6 +593,7 @@ class Exec:
print(f"{x['tag']} 入场失败 {e}", flush=True)
log_trade({"ev": "entry_fail", "key": r["key"],
"tag": x["tag"], "err": str(e)})
failed.append(f"{x['tag']} 入场:{e}")
continue
tp_px = self.snap(sym, entry + d * x["atr"] * a)
try:
@@ -562,6 +604,9 @@ class Exec:
# 超时腿会兜住它,所以只告警不强平
print(f"{x['tag']} 止盈挂单失败 {e}"
f"(仓位有服务端止损,超时腿会兜)", flush=True)
log_trade({"ev": "tp_fail", "key": r["key"],
"tag": x["tag"], "err": str(e)})
failed.append(f"{x['tag']} 止盈:{e}")
opened.append({"tag": x["tag"], "oid": oid, "size": half,
"tp_px": tp_px, "stop_px": stop_px})
print(f" {x['tag']:<7}{half} 币 · 止损 {stop_px} · "
@@ -569,7 +614,23 @@ class Exec:
log_trade({"ev": "opened", "key": r["key"], "legs": opened,
"stop_px": stop_px})
# 下单失败必须推。这类失败是**静默的经济损失**:日志里在报,但表现只是
# "一直没开仓",看起来和"没信号"一样。实盘首日就因为这个白跑 5 小时——
# 8 次下单全被 40774 拒掉而无人知道。所以推送不是可选的
if failed:
self.n_order_fail += 1
if not opened:
await tg.error(
f"{r['key']} 建仓全部失败,这个信号丢了",
"\n".join(failed) +
f"\n\n累计失败 {self.n_order_fail} 次。"
f"链路正常但下不了单——常见是下单体字段被拒"
f"(40774 持仓模式不匹配)、保证金不足、或该币被限制交易")
else:
await tg.error(f"{r['key']} 部分腿失败,收益结构已偏离设计",
"\n".join(failed))
if opened:
self.n_built += 1
self.execs[r["key"]] = {
"sym": sym, "pair": pair, "hold": hold,
"deadline": time.time() + MAXB * 60, "legs": opened,
@@ -703,19 +764,45 @@ class Exec:
continue
self.execs.pop(key, None)
def ship_state(self) -> dict | None:
"""读搬运器落的存活文件。读不到返回 None——那本身就是要报的事。"""
try:
p = self.bus.parent / "ship_alive.json"
with open(p, encoding="utf-8") as f:
return json.load(f)
except Exception: # noqa: BLE001
return None
async def heartbeat(self) -> None:
while True:
await asyncio.sleep(300)
if TG_HB_MIN and time.time() - self.tg_hb_at >= TG_HB_MIN * 60:
self.tg_hb_at = time.time()
await tg.alive(time.time() - self.t0, self.n_seen,
self.n_built, self.n_took,
sum(1 for v in self.execs.values() if v),
MAX_OPEN, self.guard.n_day, MAX_DAY,
self.guard.pnl_day, MAX_DAY_LOSS,
self.n_order_fail, self.ship_state(), self.dry)
# 跨日结算是 roll() 里同步留下的,在这里发出去
self.guard.roll()
if self.guard.pending_roll:
await tg.day_rolled(*self.guard.pending_roll)
self.guard.pending_roll = None
n_open = sum(1 for v in self.execs.values() if v)
# 「已做 N 但建仓 0」是明确的故障,不能只把数字并排列出来让人自己
# 看。首日那 5 小时的心跳里 "已做 4 · 在场 0/3" 一直在打,但没有
# 任何一处说这是异常
bad = ""
if self.n_took and not self.n_built:
bad = f" ⛔ 做了 {self.n_took} 笔却一次都没建上,下单被拒"
elif self.n_order_fail:
bad = f" ⚠ 有 {self.n_order_fail} 次下单失败"
print(f" [心跳] 见信号 {self.n_seen} · 已做 {self.n_took} · "
f"跳过 {self.n_skip} · 在场 {n_open}/{MAX_OPEN} · "
f"建仓 {self.n_built} · 跳过 {self.n_skip} · "
f"在场 {n_open}/{MAX_OPEN} · "
f"当日 {self.guard.n_day}/{MAX_DAY} 笔 · "
f"当日 PnL {self.guard.pnl_day:+.2f}/-{MAX_DAY_LOSS}",
f"当日 PnL {self.guard.pnl_day:+.2f}/-{MAX_DAY_LOSS}{bad}",
flush=True)
async def shutdown(self) -> None:
+32
View File
@@ -72,6 +72,8 @@ class Shipper:
self.last_signal_ts = 0.0
self.n_reconnect = 0
self.n_skew = 0
self.ssh_up = False
self.alive = local_bus.parent / "ship_alive.json"
def load_seen(self) -> None:
"""本地已有的键先读进来,避免重启后把整个文件再追加一遍。"""
@@ -127,6 +129,9 @@ class Shipper:
*cmd, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE)
self.connected_at = time.time()
self.ssh_up = True
self.touch_alive(0) # 立刻落盘,别等 5 分钟心跳——执行器第一轮
# 整点推送会读这个文件,晚写就会误报上游断了
print(f" ssh 已连上 {self.host}", flush=True)
assert proc.stdout is not None
try:
@@ -143,6 +148,9 @@ class Shipper:
proc.kill()
await proc.wait()
up = time.time() - self.connected_at
self.ssh_up = False
self.touch_alive(up) # 立刻标断开。只靠停更来发现的话,心跳还在
# 刷 ts,执行器会以为管道还活着
msg = err.decode("utf-8", "replace").strip()
print(f" ssh 断开(在线 {up:.0f}s,退出码 {proc.returncode}"
f"{'' + msg if msg else ''}", flush=True)
@@ -177,6 +185,30 @@ class Shipper:
f"{self.n_reconnect} 次 · 新增 {self.n_new}"
f"(重放去重 {self.n_dup})· 最近一条 {last}{skew}",
flush=True)
self.touch_alive(up)
def touch_alive(self, up: float) -> None:
"""把连接状态落到文件,供执行器的整点推送读。
为什么要落盘Telegram 推送在执行器那侧而它看不到本进程的日志
执行器活着单独没有意义搬运管道死掉时执行器一样心跳正常一样
什么都不做那正是最危险的状态所以推送里必须带上游的新鲜度
这个文件是唯一的传递途径
写失败只打日志搬运的正事是投信号不能因为写不了状态文件而中断
"""
try:
self.alive.parent.mkdir(parents=True, exist_ok=True)
tmp = self.alive.with_suffix(".tmp")
tmp.write_text(json.dumps({
"ts": time.time(), "up_s": round(up),
"connected": self.ssh_up,
"n_reconnect": self.n_reconnect, "n_new": self.n_new,
"n_skew": self.n_skew,
"last_signal_ts": self.last_signal_ts}), encoding="utf-8")
tmp.replace(self.alive) # 原子替换,读侧不会看到半个文件
except Exception as e: # noqa: BLE001
print(f" ⚠ 写存活文件失败 {type(e).__name__}: {e}", flush=True)
def main() -> None:
+62 -2
View File
@@ -8,10 +8,18 @@
平仓 带已实现盈亏含手续费与资金费这是唯一的真账
闸拦截 MAX_OPEN / MAX_DAY / MAX_DAY_LOSS 说明有 bug 或策略在流血
报错对账平仓跨日结算
整点在线 60 分钟一条见下
不推 信号过期跳过 这是常态重连重放会带上旧信号推了就淹掉真事
不推 心跳 日志里有推了每天 288
不推 5 分钟心跳 日志里有推了每天 288
量级信号 6.8 /日开仓上限 15所以最多约 30 /
量级信号 6.8 /日开仓上限 15在线 24 所以最多约 55 /
## 整点在线那条为什么不违反上面的标准
只说我还活着的推送看两天就会被忽略那时它就成了噪声所以这条必须带
**能暴露问题的数字**尤其是上游新鲜度执行器活着不代表链路活着搬运
管道死掉时执行器一样心跳正常一样什么都不做那是最危险的状态异常时这
条会显式标出来而不是把数字并排列出来让人自己看
## 失败一律只打日志
@@ -106,6 +114,58 @@ async def day_rolled(day: str, n: int, pnl: float) -> None:
await send(f"[{TAG}] {day} 结算 · {n} 笔 · {pnl:+.2f} USDT")
def _dur(s: float) -> str:
s = max(0, int(s))
if s < 60:
return f"{s}"
if s < 3600:
return f"{s // 60}分钟"
if s < 86400:
h, m = s // 3600, (s % 3600) // 60
return f"{h}小时{m}" if m else f"{h}小时"
return f"{s / 86400:.1f}"
async def alive(up_s: float, seen: int, built: int, took: int, n_open: int,
max_open: int, day_n: int, max_day: int, day_pnl: float,
max_loss: float, order_fail: int, ship: dict | None,
dry: bool) -> None:
"""整点在线。异常在第一行,正常时才是「在线」。
`ship` 是搬运器落的 ship_alive.json 解出来的字典None 表示读不到
那本身就是要报的事搬运没在跑或者跑的是没有这个文件的旧版本
"""
warn = []
if order_fail and not built:
warn.append(f"⛔ 做了 {took} 笔却一次都没建上,下单全被拒")
elif order_fail:
warn.append(f"⚠ 累计 {order_fail} 次下单失败")
if ship is None:
warn.append("⛔ 读不到搬运状态,信号可能根本没在进来")
else:
gap = time.time() - ship.get("ts", 0)
# 搬运连上/断开/每 5 分钟都会落一次。超过 12 分钟是进程自己死了
if gap > 720:
warn.append(f"⛔ 搬运状态已停更 {_dur(gap)},上游可能已断")
elif ship.get("connected") is False:
warn.append("⛔ 搬运 ssh 已断开,正在重连")
if ship.get("n_skew"):
warn.append(f"⛔ 搬运侧时钟倒流 {ship['n_skew']}")
head = warn[0] if warn else ("在线(空跑)" if dry else "在线")
lines = [f"[{TAG}] {head} · 已跑 {_dur(up_s)}",
f"信号 {seen} · 建仓 {built} · 在场 {n_open}/{max_open}",
f"当日 {day_n}/{max_day} 笔 · 盈亏 {day_pnl:+.2f}/-{max_loss:.1f}"]
if ship is not None:
last = ship.get("last_signal_ts") or 0
lines.append(
f"搬运 ssh 在线 {_dur(ship.get('up_s', 0))} · "
f"重连 {ship.get('n_reconnect', 0)} 次 · 最近信号 "
+ (f"{_dur(time.time() - last)}" if last else "启动后还没有"))
lines += warn[1:]
await send("\n".join(lines))
async def stopping(n_open: int) -> None:
await send(f"[{TAG}] 收到停机信号,平掉在场 {n_open} 笔后退出。"
f"\n注意:停机后不再有超时平仓与新开仓。"
+40 -1
View File
@@ -40,7 +40,7 @@
| 参数 | 默认值 | 含义与注意 |
|---|---|---|
| `scan` | 200 | 中枢成立后向后扫多少根找突破 |
| `scan` | 200 | 中枢成立后向后扫多少根找突破。只管找突破,入场那步由 `pullback_win` 单独限制且不受 `scan_end` 约束,故实际触达 230 根。砍掉约 30% 信号但砍的主要是废的,见 §3.6 |
| `pullback_win` | 30 | 回抽窗口 |
| `tol` | **-1.0** | 负值=禁用「回抽未触及边界就跳过」。**这是最关键的参数**,见 3.2 |
| `max_per_zone` | 1 | 同一中枢只做首次入场,见 4.2 |
@@ -1802,6 +1802,45 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算
---
### 3.6 `scan=200` 意外地落在对的位置(1m,SOL+ETH)
`scan` 从未在 1m 加当前出场结构下被单独验过。step29 的网格里它和
`pullback_win`/`tol` 联动,且跑在 15m 上用旧出场参数,分离不出单独效应。
所以它是继承下来的默认值。补测结论是:**保留 200,但理由和原先想的不同。**
先澄清两个容易搞错的口径:
1. **`scan` 只约束找突破那一步。** 入场(回抽 + 转强)由 `pullback_win=30`
单独限制,末端截到 `n` 而**不是** `scan_end`。突破落在第 199 根时入场可到
第 229 根,所以实际触达是 `scan + pullback_win` = 230 根。
2. **中枢跨度再长也不占这个窗口。** `available_ts``bis[-1]` 的确认时间,
落在中枢右边缘,窗口是从右边缘往后数的。1m 上中枢跨度中位 135~169 根、
p90 335~396、最大 1562,超 200 根的占 31~40%——常见,但不会因此漏掉突破。
真正的效应是砍信号,而砍掉的那批质量分层很陡(60000 根 × SOL/ETH,过 ATR
≥8bp 门控后 118 笔,成本按入场 taker + 40% 出场 taker + 60% maker = 3.28bp):
| 突破距 `available_ts` | 笔数 | 毛均bp | 净均bp | 胜率 | PF |
|---|---|---|---|---|---|
| ≤200(现状保留) | 70 | 16.06 | 12.78 | 44.3% | 1.86 |
| >200(现状砍掉) | 48 | 7.50 | 4.22 | 37.5% | 1.29 |
|  200600 | 33 | 3.49 | **0.21** | 30.3% | **1.01** |
|  >600 | 15 | 16.33 | 13.05 | 53.3% | 2.34 |
`scan=200` 砍掉约 30% 的信号(SOL 34/110、ETH 54/180),但砍掉的主体是
200600 那一桶——PF 1.01、扣费后净均 0.21bp,等于零。**放宽到 600 只会掺进
33 笔零边际交易稀释组合。**
唯一悬着的是 >600 那桶:和保留的那批一样好,但只有 15 笔,**不足以据此动
参数**。若这个模式为真(中枢沉寂很久后的突破质量反而高),是笔数不大但白拿
的收益。要验它得换更大样本。
样本局限:只有本地 SOL/ETH 的 1m,而 ETH 的 ATR 中位 6.1bp 大多过不了 8bp
门控,所以 118 笔里绝大部分是 SOL。真正要交易的 ADA/DOGE/XRP 本地无数据。
**所以这是"支持保留现状"的证据,不是"200 是最优值"的结论。**
---
## 4. 已验证无效 / 不要重做的方向
| 方向 | 结论 | 出处 |