何が起きたか
毎朝LINEに「ポートフォリオの最高値」を投げる通知が、日によって違う数字を出してくる。本日の更新分は確かに最高値を取ったので分かりにくいが、過去ログを並べてみると、ある日は最高値Aを出し、その翌日には最高値Aより低いBが「最高値」として出ている。最高値が下がるはずがない。
LINEのトーク履歴をスクロールして並べた時点で「これはバグだ」と確信した。実装を自分で書いた覚えがあるが、しばらく触っていない領域なので、まず Claude Code に流れを追ってもらうことにした。
調査の方針
実装は personal-notification-bots/rakuten-portfolio-tracker/update-portfolio.mjs の1ファイルに集約されている。1,600行ある中から、関連しそうな関数だけ狙い撃ちで読んでもらった。
- 履歴シート(Google Sheets)に当日のポートフォリオ評価額を upsert する処理
- そこから「最高値」を計算して LINE メッセージに差し込む処理
- 同日に何度も再実行されたときの挙動
通知文を組み立てているのは buildPersonPortfolioNotification、最高値を計算しているのは peakByPersonUpTo、履歴を upsert しているのは upsertHistory の3つに切り分けられた。
原因の特定
最高値を計算する関数は、履歴シートを priceDate ごとに走査して各人の最大評価額を返す素直な実装になっていた。ロジック単体に問題は無い。怪しいのは、その入力となる「履歴シート側」の状態だった。
ここで罠だったのは Stooq の priceDate の挙動だ。Stooq は当日中に何度叩いても priceDate を「前営業日」のまま返してくる。つまり同じ日に2回スクリプトを走らせると、同じ (priceDate, person) のキーで履歴行が2回書かれることになる。
旧実装の upsertHistory は、既存行が見つかれば「常に上書き」していた。為替やマーケットの動きで2回目の評価額が1回目より下がっていると、シート上のその priceDate の値は 下がった方の値で固定 されてしまう。最高値計算はこのシートを真とするので、当然「最高値」も後の安い値に置き換わる。
具体的に2026-06-20のログを並べて再現した。
- 1回目の実行: 評価額 X、シートに書き込まれる
- 2回目の実行: 評価額 X より少し低い Y、同じ priceDate を Y で上書き
- 翌日(2026-06-23)の通知: 最高値の候補から本来の X が消えており、Y を最高値として表示
LINEに届く「最高値」が日によってブレた正体はこれだった。
修正方針
原因が分かれば直し方は明確で、同じ (priceDate, person) で再実行されたとき、新値が既存値より低ければ上書きしない ように upsert ロジックを変える。実装は1行追加するだけで済むが、雑に書くとテストできないので、判定だけを純粋関数として切り出した。
export const decideHistoryUpsertAction = ({ existingRow, newRow, lastDate }) => {
const [priceDate] = newRow;
if (lastDate && priceDate < lastDate) return "skip-old";
if (!existingRow) return "append";
const existingValue = toNumber(existingRow[2]) ?? 0;
const newValue = toNumber(newRow[2]) ?? 0;
if (newValue <= existingValue) return "skip-lower";
return "update";
};
skip-lower を追加したのが今回の修正点。upsertHistory 本体はこの関数の戻り値を switch 的に分岐するだけにして、副作用(Google Sheets への書き込み)と判定ロジックを分離した。
もう一つ、最高値計算側の peakByPersonUpTo にも当日の priceDate を peak 候補から除外しないように手を入れた。当日除外を続けると、skip-lower で安い方を弾いても、当日の正しい高値が候補から消えてしまうケースが残るためだ。コメントに「2026-06-20 ログで実証」とまで書いてある。これは未来の自分が「なんでこのロジックなんだっけ」と忘れたときの保険。
テスト
純粋関数として切り出した目的は、当然テストを書くため。Claude Code にテストケースを足してもらった。
decideHistoryUpsertAction: 新値が既存より低い →skip-lower、高い →update、新規 →append、過去日付 →skip-oldpeakByPersonUpTo: 当日の prior peak が保持される(同日再実行で安値を入れても最高値が消えない)peakByPersonUpTo: 未来の priceDate は除外
既存の31件のテスト全てが通過することを確認して終わり。バグの再発防止が、判定ロジックそのものへのアサーションとしてコードに残った。
振り返り
LINEを毎朝眺めていれば異常には気づくが、原因の特定はログを並べて見ないと分からなかった。実装当時は「同じ priceDate なら常に上書きが安全」と思ってそう書いた記憶がある。Stooq の priceDate の挙動と、同日に複数回走るというワークフローを掛け合わせた時に初めて顔を出すバグだった。
教訓は2つ。
- 外部APIが返す日付フィールドは「時刻軸」とは独立に動くと疑う。Stooq の priceDate は「データの基準日」であって「実行時刻」ではない
upsertという名前を付けた瞬間に「上書きしてよい根拠」を一度考える。今回は「新値が高ければ上書き」が根拠で、それを満たさない再実行は弾くべきだった
Claude Code に流れを追わせて、原因を特定してもらい、純粋関数として切り出してもらい、テストを書いてもらった。自分でやったのは「LINEを見て違和感を拾った」「実装当時の思想を思い出して、何が抜けていたかを言語化した」の2つだけ。AIに任せて回す部分と、自分で違和感を拾う部分の役割分担が、今回もちょうど良かった。
関連ファイル
- 実装本体:
personal-notification-bots/rakuten-portfolio-tracker/update-portfolio.mjs - テスト:
personal-notification-bots/rakuten-portfolio-tracker/test/update-portfolio.test.mjs