原文:https://dev.to/wataru_suda_d295dab9cca4f/a-16-second-clock-drift-falsely-tripped-my-trading-bots-kill-switch-a-postmortem-4k2f(作者 @wataru_suda_d295dab9cca4f)
我运行着一个小型、刻意设计得很枯燥的交易机器人:日线级别、仅现货交易、不使用杠杆,本金约24,000日元,放在日本交易所GMO Coin上。它每天早上06:05醒来,查看昨日K线,通常什么都不做。
到了第六天,它自行停机,并报告了58%的回撤。而我的账户余额一分钱都没变。
(该系统的免费精简版回测工具,仅使用公开API,无需密钥:GMO Coin Trend Lab,源码在 GitHub。)
没有任何实际损失,没有订单被发出。但这个bug很有意思,因为链条上的每一个环节单独看似乎都合理。
事件时间线
- 电脑已关机三天。重新开机后,Windows尚未同步时间,本地时钟快了约16秒。
- 定时任务触发。机器人使用本地时间戳签署了第一个私有API调用。
- 交易所拒绝了请求:
ERR-5009 Timestamp for this request is too fast.(请求时间戳过快) - 我的启动代码将“密钥验证失败”视为“没有可用密钥”,于是回退到了模拟交易模式。这个回退机制是我在第一天写的,目的是为了防止粘贴错误的密钥导致程序崩溃。
- 模拟交易模式有自己独立的虚拟资金10,000日元,但它与实盘模式共享同一个状态文件。
- 机器人计算权益为10,000日元,与存储的高水位线23,900日元比较,得出了58%的回撤率,并严格按照预设规则——30%回撤时平仓停机。
因为处于模拟模式,“平仓”并未实际下单。然而,停机标志被写入了真实的状态文件。这个机器人会一直保持停机状态,直到人工干预。
为什么每个独立决策看起来都没问题
“如果密钥验证失败,则回退到模拟模式。” 在第一天很友好,当时最可能的错误是用户把外汇密钥粘贴到了加密货币槽位。但在第六天,密钥本身没问题,故障是瞬时性的,这就危险了。
“使用同一个状态文件。” 很简单。直到两种不同资金余额的模式写入了同一个高水位线。
“30%回撤时停机。” 这个规则是正确的,而且生效了。紧急停机开关本身不是bug。bug在于,一个来自世界A的数字,与一个来自世界B的数字进行了比较。
修复一:使用服务器时钟而非本地时钟签名
交易所会检查你的时间戳是否接近它自己的时间。所以,向它询问时间。每个HTTP响应都携带一个Date头部,这对于秒级容差来说已经足够精确了。
import email.utils, time, requests
PUBLIC = "https://api.coin.z.com/public"
class GmoClient:
def __init__(self):
self.s = requests.Session()
self._offset = None # 本地时钟减去服务器时钟的偏移量,单位秒
def clock_offset(self, refresh=False):
if self._offset is None or refresh:
try:
t0 = time.time()
r = self.s.get(PUBLIC + "/v1/status", timeout=15)
t1 = time.time()
srv = email.utils.parsedate_to_datetime(r.headers["Date"]).timestamp()
self._offset = (t0 + t1) / 2 - srv
except Exception:
self._offset = 0.0
return self._offset
def _timestamp_ms(self):
# Date头部的精度是秒,所以提前半秒:
# “稍微慢一点”会被容忍,“太快了”会被拒绝
return str(int((time.time() - self.clock_offset() - 0.5) * 1000))
两个细节很重要。取请求开始和结束时间的中点,这样网络延迟就不会影响估算。另外要略微提前,因为这个API对未来时间戳的拒绝比对近期过去的时间戳更严格。
经过这个修改,机器人不再关心操作系统时钟是否正确。
修复二:验证失败不得触碰实盘状态
c = GmoClient()
if not c.dry_run: # 密钥存在,我们打算实盘运行
try:
c.assets()
except GmoError as e:
log(f"key validation failed, aborting this run without touching state "
f"(clock offset {c.clock_offset():+.1f}s): {e}")
return # 明天再试
if c.dry_run:
STATE = ROOT / "state_dry.json" # 模拟交易使用自己的状态文件
我总结出的规则是:如果系统本意是实盘运行,但无法证明自己处于实盘状态,它应该什么也不做,并发出明确警告。 而不是“优雅降级”到另一个与真实模式共享存储的模式。
编写回退逻辑时,我现在会检查什么
- 回退逻辑会写入什么,写到哪里?如果它与正常路径共享存储,那它就不是一个回退,而是第二个写入者。
- 我捕获的故障是永久性的(错误的密钥)还是瞬时性的(时钟、网络、维护窗口)?瞬时故障应该中止并在之后重试,而不是改变模式。
- 我会注意到吗?停机是静默的,直到我主动去查看。现在机器人每十分钟写一次仪表板状态,停机时会显示红色横幅。
- 安全机制会接收到垃圾数据吗?紧急停机开关的效果,取决于输入给它的权益数值的质量。
关于策略本身的无聊数据,既然有人问
策略本身是20日突破加50日过滤,使用2倍ATR止损,每笔交易风险1%。我用交易所自己2018年至2026年的日线数据进行回测,得到的回报率与波动率目标化的买入持有策略大致相同,但回撤大约只有后者的一半,在震荡市的年份收益接近零。滚动前推样本外夏普比率为0.85。它的第一笔实盘交易发生在第八天:0.0076 ETH,风险约240日元。
运营上的漏洞比策略亏损更让我害怕。这次事件的成本为零,这是最好的学习价格。
如果你想试用这个回测工具,精简版是免费下载的(仅需公开API,无需密钥):https://wataflow1.gumroad.com/l/trend-lab-free
不构成投资建议。
原文:https://dev.to/wataru_suda_d295dab9cca4f/a-16-second-clock-drift-falsely-tripped-my-trading-bots-kill-switch-a-postmortem-4k2f(作者 @wataru_suda_d295dab9cca4f)



