补上「一个币一个仓位、账户专用」这两个没落在代码里的前提

三处都源于同一个隐含假设从未被写成代码。

① 同币并发会让 pnl_day 翻倍。blocks() 只查幂等键与三条计数闸,没有按币的
占用检查。同币开两笔时交易所净成一个仓位,于是 watch() 按 pair 判出场会让
两个 key 同时进 gone,_match_hist 给它们返回同一条历史记录,realized() 被
调两次。而 pnl_day 正是 MAX_DAY_LOSS 读的数:亏损翻倍提前停机,盈利翻倍让
闸变迟钝。sweep() 也会在第一笔截止时平掉合并后的整个仓位。

合并后的行为(一个止损、两个不同价位的止盈、超时一锅端)不是任何一版回测
建模的东西,所以在 on_signal 里跳过第二个信号,是最接近安全的近似。期望并发
0.18 笔,损失极小。另外给 _match_hist 加 positionId 独占认领,让这个不变量
在记账处本地成立,而不是依赖两百行外的检查——花钱的路径值得两道。

② positions() 返回账户全部仓位,三个调用点都没过滤。账户上任何第三方仓位
都会被 reconcile 在重启时市价平掉,而 watch() 会因该 symbol 一直在场而永不
结算,MAX_OPEN 名额泄漏、pnl_day 不再更新。加 PAIRS 过滤与 my_positions()。
残留局限记在注释里:同币上的第三方仓位仍分不出来,账户仍应专用。

③ oid_of 把非字母数字换成下划线,理由是 : 和 + 未必被接受,但调用方又拼了
"-tp" 把 - 加回去,自相矛盾。真被拒时止盈单会全部挂不上,而那条路径只告警
不停机,收益结构静默退化成「只有止损 + 超时」,且空跑验不到(dry 返回假
成功)。改用 _tp,并把字符集约束写进 oid_of 的文档。

实测:同币第二个信号被挡、别币放行、独占认领不重复计账、PAIRS 排除
PEPEUSDT、全部 clientOid 只含字母数字下划线。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jack
2026-08-28 18:58:47 +08:00
co-authored by Cursor
parent 5ffbadb0b2
commit 881cc5e77d
+55 -8
View File
@@ -87,6 +87,12 @@ LIVE_HOME = Path(os.environ.get("LIVE_HOME", Path.home() / "chan-live"))
SYMS = os.environ.get(
"SYMS", "BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC").split(",")
# 只认这些交易对。交易所的 all-position 返回**账户全部**仓位,不过滤的话
# 账户上任何第三方仓位(手工单、另一个策略、试单忘了平)都会被 reconcile
# 在下次重启时市价平掉;watch() 还会因为该 symbol 一直在场而永不结算对应的
# keyMAX_OPEN 名额泄漏、pnl_day 不再更新。把「账户只归执行器」这个前提
# 从口头约定变成代码里的过滤
PAIRS = frozenset(f"{s}USDT" for s in SYMS)
NOTIONAL = float(os.environ.get("LIVE_NOTIONAL", "500"))
LEVERAGE = int(os.environ.get("LIVE_LEVERAGE", "10"))
@@ -217,6 +223,11 @@ def oid_of(key: str, tag: str) -> str:
clientOid 是**交易所级幂等**:重发同一个 oid 会被拒。这比本地去重可靠,
因为「已发出但没收到回复」这种情况本地判不了,重试就会开两次仓。
调用方拼后缀时也要守这个字符集(用 `_tp` 而不是 `-tp`)。曾经拼过 `-`,
和这里的理由自相矛盾;真被拒的话止盈单挂不上,而那条路径只告警不停机,
收益结构会静默退化成「只有止损 + 超时」,空跑还验不到(dry 直接返回
假成功)。
"""
# 方向必须显式编码:直接把非字母数字换成下划线,会让 `+1` 和 `-1` 都变成
# `_1`,同一根上的多空信号得到相同 oid,第二笔被交易所当重复拒掉
@@ -368,7 +379,7 @@ class Exec:
且行为确定。
"""
try:
pos = await self.api.positions()
pos = await self.my_positions()
except Exception as e:
print(f" ⚠ 对账读持仓失败 {type(e).__name__}: {e}", flush=True)
return
@@ -417,6 +428,15 @@ class Exec:
# 一条记录要能下单,这些字段一个都不能缺
NEEDED = ("key", "sym", "emit_ms", "direction", "entry_px", "atr_pct")
async def my_positions(self) -> list:
"""只看 SYMS 里那些币的仓位。
**不要**直接用 api.positions():它返回账户全部仓位,理由见 PAIRS。
残留的局限要知道:同一个币上的第三方仓位(比如你手工开了 ADA)仍然
分不出来,那需要按 clientOid 逐单认领,成本不划算。所以账户仍应专用。
"""
return [p for p in await self.api.positions() if p["symbol"] in PAIRS]
async def on_signal(self, r: dict) -> None:
# 先校验再取值。缺了这一步,总线上一条字段不全的记录会抛 KeyError,
# 把 poll 任务打死,进而**整个执行器停止交易**——一行坏数据换全面停摆,
@@ -432,6 +452,21 @@ class Exec:
age = time.time() - r["emit_ms"] / 1000.0
n_open = sum(1 for v in self.execs.values() if v)
why = self.guard.blocks(r["key"], n_open)
# 同币占用检查。整套记账隐含「一个币最多一个仓位」这个前提,但它原先
# 只存在于口头上。同币开两笔时交易所会**净成一个仓位**,于是:
# · watch() 按 pair 判出场,两个 key 同时进 gone_match_hist 给它们
# 返回同一条历史记录,realized() 被调两次 → pnl_day 翻倍。而这正是
# MAX_DAY_LOSS 读的数:亏损翻倍会提前停机,盈利翻倍会让闸变迟钝
# · sweep() 在第一笔截止时平掉合并后的整个仓位,把第二笔才持有十分钟
# 的部分一并平掉
# 合并后的行为(一个止损、两个不同价位的止盈、超时一锅端)不是任何一版
# 回测建模的东西,所以跳过第二个信号是最接近安全的近似。期望并发 0.18
# 笔,这一跳损失极小
if why is None:
dup = [k for k, v in self.execs.items() if v["sym"] == r["sym"]]
if dup:
why = (f"{r['sym']} 已有在场仓位 {dup[0]}——同币开两笔会在"
f"交易所侧净成一个仓位,把盈亏记账和超时腿都搞错")
if why is None and age > STALE_S:
why = f"信号已过期 {age:.1f}s > {STALE_S:.0f}s"
if why:
@@ -521,7 +556,7 @@ class Exec:
tp_px = self.snap(sym, entry + d * x["atr"] * a)
try:
await self.api.tp_limit(pair, close_side, half, tp_px,
oid + "-tp")
oid + "_tp")
except Exception as e:
# 入场成了但止盈没挂上:仓位仍有服务端止损,不是裸仓。
# 超时腿会兜住它,所以只告警不强平
@@ -564,7 +599,7 @@ class Exec:
if self.dry or not self.execs:
continue
try:
live = {p["symbol"] for p in await self.api.positions()}
live = {p["symbol"] for p in await self.my_positions()}
except Exception as e: # noqa: BLE001
print(f" ⚠ 盯仓读持仓失败 {type(e).__name__}: {e}", flush=True)
continue
@@ -581,9 +616,13 @@ class Exec:
print(f" ⚠ 读历史持仓失败 {type(e).__name__}: {e}"
f"本轮不结算(下轮重试,不会漏)", flush=True)
continue
# 一条历史记录只能被一个 key 认领。同币并发已在 on_signal 拦住,
# 但记账是花钱的路径:让这个不变量在本地成立,而不是依赖两百行外
# 的另一处检查。重复认领会让 realized() 被调两次、pnl_day 翻倍
claimed: set = set()
for key in gone:
st = self.execs[key]
rec = self._match_hist(hist, st)
rec = self._match_hist(hist, st, claimed)
if rec is None:
# 常见于刚平掉、历史还没落库。留着下轮再试;真丢了也有
# 48 分钟截止那条路兜住 execs 的清理
@@ -605,21 +644,29 @@ class Exec:
self.guard.pnl_day, self.guard.n_day)
@staticmethod
def _match_hist(hist: list, st: dict) -> dict | None:
def _match_hist(hist: list, st: dict,
claimed: set | None = None) -> dict | None:
"""在历史持仓里认领属于这一笔的记录。
按 symbol + holdSide 匹配,并要求收盘时间不早于入场时间(减 60s 容差,
两边时钟与落库都有抖动)。同一 symbol 有多条时取最近的一条。
`claimed` 装已被别的 key 认走的 positionId,防止两个 key 认到同一条。
"""
best, best_t = None, -1.0
best, best_t, best_id = None, -1.0, None
for r in hist:
if r.get("symbol") != st["pair"] or r.get("holdSide") != st["hold"]:
continue
pid = r.get("positionId")
if claimed is not None and pid is not None and pid in claimed:
continue
t = float(r.get("utime") or r.get("uTime") or 0)
if t < st["opened_ms"] - 60_000:
continue
if t > best_t:
best, best_t = r, t
best, best_t, best_id = r, t, pid
if best is not None and claimed is not None and best_id is not None:
claimed.add(best_id)
return best
async def sweep(self) -> None:
@@ -635,7 +682,7 @@ class Exec:
if now < st["deadline"]:
continue
try:
pos = [p for p in await self.api.positions()
pos = [p for p in await self.my_positions()
if p["symbol"] == st["pair"]]
if not pos:
print(f"{key} 超时前已全部出场", flush=True)