补上「一个币一个仓位、账户专用」这两个没落在代码里的前提
三处都源于同一个隐含假设从未被写成代码。 ① 同币并发会让 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:
+55
-8
@@ -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 一直在场而永不结算对应的
|
||||
# key,MAX_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)
|
||||
|
||||
Reference in New Issue
Block a user