2026/10/19

最近接手一個股票報酬率分析工具:使用者上傳券商對帳單截圖或 CSV,系統算出持股損益、各期間報酬率、損益日曆。
起點是主管用 vibe coding 做出來的原型:一個單頁 HTML 加上幾支 JS,狀態放在全域變數,資料存在瀏覽器,AI 辨識由前端直接呼叫模型。我的任務是把它移植進公司的 React 產品裡。
原型的功能其實很完整,畫面也做得很好。真正花時間的不是「把它改寫成元件」,而是弄清楚它哪裡會悄悄算錯。這篇記錄我的做法。
原型的狀態集中在一個全域物件裡,每次改資料都要手動清快取、手動重畫。寫法大致是這樣:
// 示意
function onDataChanged() {
cachedResult = null;
save();
renderAll();
}這種寫法的問題是:每新增一條修改資料的路徑,都要記得呼叫這個函式。忘了一次,畫面就跟資料不同步。
React 版把狀態依「誰擁有它」分成三類:
| 狀態 | 放哪裡 | 理由 |
|---|---|---|
| 交易、庫存、設定、股價 | TanStack Query | 資料在後端,需要快取、失效、mutation 生命週期 |
| 排序、顯示模式、輸入模式 | 元件範圍的 Zustand store | 純 UI 狀態,元件卸載就該銷毀 |
| 持股、損益、報酬率 | useMemo 衍生 | 由上面兩者算出來,不該另存一份 |
第三類是關鍵。原型是「另存一份計算結果,再想辦法讓它不過期」;改成衍生值之後,快取失效這件事直接消失,上游資料一變就重算。
計算邏輯則全部抽成純函式:不依賴 React、狀態從參數傳入而不是讀全域變數。這讓費用公式、成本計算、報酬率都能寫單元測試,後面那些 bug 才有辦法被釘住。
移植時我開了一份文件,專門記錄「原型有問題、React 版修掉」的地方,每一條都附上原型的出處,最後累積了十幾條。
整理完發現它們有個共同點:沒有一條會報錯。全部都是畫面上出現一個看起來正常的數字,但那個數字是錯的。挑幾個代表性的:
成交價金的算法是「股數 × 單價,無條件捨去」。直接寫成 Math.floor(qty * price) 看起來沒問題,但 JS 的數字是二進位浮點數:
3000 * 19.15 // 57449.99999999999 → floor 後變 57449,正確是 57450成交價金是手續費、交易稅、淨收付的起點,少 1 元會一路傳下去,而且只有特定單價會中。使用者拿券商對帳單比對時,只會看到某幾筆莫名差 1 元。
修法是先收掉尾差再捨去:
const calcAmount = (qty: number, price: number): number =>
Math.floor(Number((qty * price).toFixed(4)));收到小數四位只會吃掉浮點誤差:股數是整數、單價最多兩位小數,真實乘積最多兩位小數,3 × 500.5 = 1501.5 照樣捨成 1501。用 toFixed 而不是 Math.round(x * 1e6) / 1e6,是因為金額大時乘上 1e6 可能超過安全整數範圍。
原型算總損益時,成本是全部持股加總,市值卻只加「有抓到股價的」。
假設 A 成本 50 萬、市值 60 萬;B 成本 20 萬,但股價剛好抓不到:
| 原型 | 實際 | |
|---|---|---|
| 總損益 | 60 − 70 = −10 萬 | A 賺 10 萬,B 未知 |
| 總報酬率 | −14.3% | A 是 +20% |
連正負號都反了,而且畫面上沒有任何提示。興櫃股、剛下市、新掛牌,股價來源對個別代號沒資料是很常見的事。
修法是沒報價的股票不算進總市值、總損益與總報酬率,並在畫面上寫出「N 檔無報價,未計入總損益與總報酬率」。算不出來可以接受,算錯而且不說才是問題。
原型解析 CSV 時,缺代號、股數不合法、缺價格、缺日期的列,都直接 continue 跳過,不留任何紀錄。
丟進 6 筆,預覽只出現 2 筆。使用者不知道少了哪幾筆、為什麼少,而且就算知道也修不了,因為那幾列根本不在畫面上。
更糟的是「缺日期就跳過」:很多券商對帳單的日期欄是合併儲存格,只有第一列有日期,這種檔案會被丟掉九成。
React 版改成每一列都列出來,標明原因(「股數無效」「缺單價」),有錯誤的列擋住送出。重點是改好就立刻放行:在預覽表補上股數按 Enter,錯誤消失、可以送出。判斷規則抽成同一個函式,解析當下與行內編輯後共用,避免兩邊各寫一份、日後漂移成「明明改好了卻還是擋」。
原型的還原是無條件「清空交易與庫存,再寫入備份檔的內容」。備份檔完整時沒問題;但「只匯出交易」的檔案也會被認成備份檔,拿它還原 → 庫存被清空 → 檔案裡沒有庫存可以寫回去 → 永久消失。
修法是讓還原流程知道目前有什麼,算出差集:
// 示意
const inventoryWillBeLost = currentInventoryCount > 0 && backupInventoryRows.length === 0;判斷的是差集而不是檔案本身,所以同一份只有交易的檔案,對還沒建庫存的使用者就不會跳警告。
偵測到會掉資料時,確認按鈕的文字改成「確認刪除 3 檔庫存並還原」。使用者常常跳過警告直接按最後一顆按鈕,所以損失要寫在按鈕上。我刻意沒有停用按鈕:「就是要清掉庫存、只還原交易」是合法需求,停用等於拿掉功能。
原型的顏色變數有一組 gain(漲,紅色)和 loss(跌,綠色),照台股慣例漲紅跌綠。
但 loss 長得很像英文的「失敗」,結果費用驗算失敗這個錯誤狀態用了 loss,錯誤訊息變成綠色,看起來像「沒事」。
「漲跌」和「成敗」是兩條不同的語意軸,共用一組變數就會撞。React 版另外加了錯誤專用的顏色變數,並把原因寫成註解放在定義旁邊,下次有人想拿跌色當錯誤色會先看到。
匯出 CSV 時,原型直接把欄位用逗號串起來,完全沒有跳脫。兩個後果:股票名稱裡一個逗號就讓整列欄位錯位;= 開頭的值會被 Excel 當成公式執行(OWASP 稱為 CSV Injection)。
我加了跳脫:含逗號或換行就包引號,= + - @ 開頭的值前置單引號讓 Excel 當文字。但 -1234 是數字不是公式,所以排除了純數字:
/^-?\d+(\.\d+)?$/結果報表的每一個負的損益都多了一個引號。報表的金額經過千分位格式化(-36,600),報酬率帶百分比(+80.39 %),兩種都不符合上面的規則。修了一個安全問題,卻把每天在看的報表弄花。
後來改成照報表實際輸出的形狀排除:
/^[+-]?[\d,]+(\.\d+)?\s*%?$/+1+1 這種數字後面還接運算子的照樣會被擋。
這個錯誤讓我學到:測試案例要從真實的輸出形狀來,不是從腦中想像的輸入來。
這個系統有 AI 辨識截圖,但我的原則是 AI 是最後一道,不是第一道:
圖片 → AI(沒有結構,只能靠視覺)CSV 欄名認得出 → 本地解析(關鍵字比對就夠了)CSV 欄名認不出 → AI 文字辨識(兜底)系統備份檔 → 本地解析,絕不送 AI備份檔是我們自己產生的格式,百分之百確定。送給 AI 只有壞處:AI 的輸出格式裡沒有「這列是庫存還是交易」的概念,還原後庫存會全部變成交易;設定列會被當成一列壞資料;還多一次呼叫和費用。
AI 回來的資料也不直接信。單價和淨收付都有時,前端會反推隱含的手續費折數,落在合理範圍外就判定 AI 讀錯,標成錯誤讓使用者修。
券商截圖上常有戶名、帳號。送出前的處理全部在瀏覽器裡做:
自動規則只是輔助,最後的保證是使用者看過實際要送出的內容並勾選確認。
這個專案的程式碼大部分是 Claude Code 產生的,這點我不打算隱瞞。但從上面幾個例子應該看得出來,產程式碼從來不是瓶頸,瓶頸是判斷什麼是對的:
我的做法是讓每個決策都留下文件:修了什麼、原型的出處、為什麼這樣修、刻意沒修什麼。文件裡還有一節專門記錄「移植過程中我們自己弄壞的」,分開放是為了不把自己造的 bug 記到原型頭上。
買賣標籤的顏色對調就是一個例子。原型的買進是紅字、賣出是綠字,寫法是對的。移植時 Claude Code 把寫死的色碼換成顏色變數,卻把買進接到 loss、賣出接到 gain,兩個對調了。
它一直沒被發現,是因為底色是另外寫死的,而且寫對了。畫面上是「紅底綠字」,不夠突兀到讓人停下來看。我是在比對原型和 React 版的畫面時才注意到,追下去發現跟上面錯誤訊息變綠色是同一個根因:gain/loss 這組名字讓人(也讓 AI)以為是「成功/失敗」。修法我交給 AI 做,但「為什麼會錯、還有哪裡可能一樣錯」是我要回答的問題。
AI 照字面理解變數名稱,這種錯它自己不會發現。
專案進行中後端同事離職,交易、庫存、設定、AI 辨識的 API 由我接手,寫在公司既有的 PHP 後端上。端點分三組:
改成後端呼叫 AI,順手解決了原型最大的安全問題:原本 API Key 存在使用者的瀏覽器裡。
從前端轉去寫後端最大的感受是:前端假設的事情,搬到後端後就沒人保證了。原型時代,「AI 回的買賣別要轉成固定格式」「淨收付一律正值」這些規則寫在前端或 prompt 裡;搬到後端後,它們變成只有文件約定、沒有程式強制。而這些規則一旦沒做到,前端不會報錯,只會讓買單被當成賣單算。
所以我先寫 API 契約文件,把這類「沒做也不會壞、只會算錯」的約定逐條列出來,前端再加一層執行期校驗當保底。
回頭看,原型能跑、功能齊全、畫面好看,但「能跑」和「數字是對的」之間,隔著這十幾條沒有任何錯誤訊息的 bug。
對財務數字來說,最危險的從來不是當機,是一個看起來很合理的錯誤數字。
這些「不會報錯的錯」只能靠測試釘住,不然下一次重構又會悄悄回來。
也有做不到的:瀏覽器端到端測試目前是 0,完整的畫面操作目前靠人工實測。這是我接下來最想補的一塊,因為像「確認按鈕的文字有沒有寫出損失」這種行為,單元測試只驗得到元件,驗不到使用者實際走一遍的流程。