山寨幣現貨 · 線上事故
一個算損益給人看的函式,讓賣出持倉這件事沒有發生
更新 2,161 字 · 約 6 分鐘
2026-08-16 的線上事故:CSV 多了一欄但表頭沒跟著換,讀檔炸掉,而那個讀檔的函式只是用來算損益給人看的。它掛在交易關鍵路徑上,於是平倉在記憶體發生、在磁碟沒發生,現貨從沒賣掉,心跳停了 88 分鐘,發現的人是使用者。
更正(2026-08-27):本文原先把這條線稱為「幣圈永續合約」,那是錯的。訊號取自永續合約(資金費率、未平倉),而部位建在現貨(Gate 現貨)。線名已更正為「山寨幣現貨」,其餘內容與數字未變。
2026-08-16,一個標的在 11:23 觸發出場,13:01 才真的賣掉,晚了 98 分鐘。中間系統以為自己每五分鐘都在正常運作,實際上它每五分鐘把同一筆平倉重寫一次,總共寫了 17 筆重複列,而現貨一股都沒賣。
根因不在下單邏輯,也不在訊號。是一個算損益給人看的函式讀檔失敗,把整條交易迴圈拖下去。
根因:表頭沒有跟著欄位定義走
前一次改動給掃描器的欄位定義加了一欄,11 欄變 12 欄。但寫入函式只在「檔案不存在」時寫表頭 —— 檔案早就存在,所以表頭還是舊的 11 欄,而新的資料列是 12 欄。
一個 CSV 檔案,前面幾千列 11 欄,後面幾列 12 欄。
下次讀它的時候:
ParserError: Expected 11 fields, saw 12
到這裡為止,這是一個五分鐘就能修好的小 bug。真正的問題是它被讀在哪裡。
七環故障鏈,每一環都放大上一環
| # | 發生什麼 | 放大成什麼 |
|---|---|---|
| 1 | 損益函式讀 CSV 炸 ParserError | 一個顯示功能壞掉 |
| 2 | 它被呼叫在平倉通知那一行 | 顯示用函式進了交易關鍵路徑 |
| 3 | 例外往上拋,掃描迴圈整個中止 | 狀態存檔沒跑到 |
| 4 | 平倉在記憶體發生、在磁碟沒發生 | 下個週期重載舊狀態、重新平倉、再寫一列 |
| 5 | 執行層排在掃描層之後 | 從沒執行 → 現貨從沒賣掉 |
| 6 | 例外處理只寫 log、不通知 | 零告警 |
| 7 | 心跳也排在掃描層之後 | 心跳停了 88 分鐘 |
每五分鐘一輪,80 分鐘寫了 17 筆重複列。
🔴 發現的人是使用者,不是系統。
第 3 環是整條鏈的樞紐
平倉這件事在程式的記憶體裡完成了:部位被標成已關閉、通知被組出來。然後在寫進磁碟之前,一個跟交易無關的函式拋了例外,迴圈中止,狀態存檔那一行永遠沒被執行到。
下一輪重新啟動時,程式從磁碟讀回的是「這個部位還開著」。於是它再平一次。再拋一次。再重來一次。
這是資料庫工程裡最老的一條規則被違反的後果。PostgreSQL 的 Write-Ahead Logging 說明(查證日期 2026-08-27)把它寫成一句話:對資料檔的更動,必須在更動已經被記錄下來之後才能寫入。順序反了,崩潰之後就分不清哪些做了、哪些沒做。
我的迴圈違反的正是這個順序:先做事,再存檔,而且中間還夾了一個會拋例外的顯示功能。
第 6 環讓前面六環全部沒有被看見
daemon 的例外處理有寫,只是它寫的是 log。
Google SRE Book 對「警報」的定義是:一則要給人讀、而且會被推送到人面前的通知(查證日期 2026-08-27)。照這個定義,我那個 daemon 從頭到尾沒有發出任何警報。它只是把事情寫在一個沒有人會去看的地方,然後繼續假裝在工作。
一個沒有人被推送的錯誤紀錄,和沒有錯誤紀錄,在事故當下的效果完全一樣。
實際損害:晚 98 分鐘,而且晚賣多賺
| 事件 | 時間 | 當下報酬 |
|---|---|---|
| 規則觸發出場 | 11:23 | +50.9% |
| 實際賣出 | 13:01 | +54.2% |
晚了 98 分鐘,多賺 3.3 個百分點。
這是這篇最需要講清楚的一段。 如果我只看損益表,2026-08-16 那天是漂亮的:一筆 +54.2% 的平倉,出場原因欄寫著量能高潮,帳面完全正常。那一筆的紀錄公開在〈績效〉的已平倉逐筆表裡,數字是真的。
但那 3.3 個百分點是運氣,不是設計。同一條故障鏈遇到一根下跌的 K 線,就是反過來的結果,而且損失沒有上限 —— 98 分鐘裡沒有任何機制會停下來。
結果好的事故比結果壞的事故更危險,因為它不會促使任何人去追。我這次會追,只是因為使用者剛好看到心跳停了。
修法:四層,每一層單獨成立
- 表頭與欄位定義不符就地遷移。 寫入前必叫,不是「檔案不存在才寫表頭」。同時給
DictWriter加上extrasaction='ignore'—— Python 官方文件寫的是,設成ignore時字典裡多出來的鍵會被忽略而不是拋例外(查證日期 2026-08-27)。這一條讓「欄位定義變動」不再是一個會炸的事件。 - 損益函式包 try/except,任何失敗都回 None。 顯示壞掉是小事,交易迴圈停掉不是。
- 🔴 平倉函式先存檔,再做任何可能失敗的事。 這是四條裡唯一一條就算前三條都沒做也能單獨救回這次事故的。
- 通知函式包 try/except。 通知失敗不可以擋住後面的部位。
四層是刻意的:第 1 層修的是這次的觸發原因,第 2 到第 4 層修的是下一次不同的觸發原因。只修第 1 層等於賭下次不會有別的東西在同一個位置拋例外。
自檢釘住三件事:表頭會遷移、損益函式不拋例外、存檔發生在寫入之前。
資料修復:把一筆從沒發生的平倉刪掉
17 筆重複列去重之後剩 2 列。但真正需要決定的是另一件事:
11:23 那一筆平倉列,要留還是要刪?
它從沒真的執行:2026-08-16 當天現貨還在帳戶裡。留著它,帳本就會說一個沒發生過的謊;而這個站的整個賣點是帳本不說謊。所以移除,該部位回到未平倉狀態,接下來由規則自己決定。
重啟的時候量能仍然在門檻上,規則立刻再次觸發,這次真的賣掉了。那才是 13:01 那一筆。
刪一列已經寫進檔案的紀錄,跟這個站「歷史不可竄改」的原則看起來衝突。分界線是:不可竄改的是發生過的事,不是程式誤寫的東西。 一筆沒有對應成交的平倉列不是歷史,是 bug 的產物。判準與更正政策寫在〈方法論〉。
一般性教訓
顯示用的東西不准出現在交易關鍵路徑上。
這條 bug 的殺傷力不在 CSV 有幾欄,在於「算損益給人看」這件事有能力讓「賣出持倉」這件事不發生。
2026 年寫下那一行的時候,它看起來完全無害:平倉之後順手算一下損益,通知裡好看一點。沒有人會覺得那是交易邏輯。但它在同一個 try 區塊裡、在存檔之前、在執行層之前,所以它就是交易邏輯的一部分。
同一個專案裡的另外三個 bug 是同一類:自檢腳本掃錯關鍵字所以漏掉整個下單層、preflight 印「全部通過」而券商正在非同步回錯誤、dry run 也印出 sent。它們都不會拋例外,它們只在真的花錢那天才顯形。〈我的回測說 13.25 倍,稽核完剩 2.26 倍〉那篇講的是同一種病在回測端的樣子。
所以寫完任何守衛或回報,順序是:先問「它會不會因為錯誤的理由通過」,再問「它對嗎」。
常見問題
為什麼不用資料庫就好? 會少掉這一類的問題,但代價是多一個要維護的東西。真正的教訓不是「別用 CSV」,是「別把可能失敗的顯示功能放在狀態存檔之前」—— 換成資料庫,第 2、3、4 層的錯誤照樣會發生。
心跳停了 88 分鐘,為什麼沒有告警? 因為 2026-08-16 那個版本的心跳排在會炸的那一段之後。監控自己被監控的東西拖死,是這條鏈裡最沒有防禦的一環。修法是把心跳搬到迴圈最前面,跟業務邏輯解耦。
那一筆最後賺了,為什麼還算事故? 因為賺是運氣。同一條鏈在下跌時會產生無上限的損失,而 98 分鐘裡沒有任何東西會踩煞車。用結果判斷流程對不對,正是這個站在反對的事。
這件事會再發生嗎? 第 1 層(表頭遷移)讓這個觸發原因不會再出現;第 2 到 4 層讓別的觸發原因不會造成同樣的後果。自檢釘住三件事,改壞了會被擋在建置之前。