[{"data":1,"prerenderedAt":324},["ShallowReactive",2],{"content-/portfolio-highest-value-bug":3,"all-pages-for-dir":322,"og-image-/portfolio-highest-value-bug":323},{"id":4,"title":5,"body":6,"category":302,"description":303,"extension":304,"meta":305,"navigation":306,"ogImage":307,"path":308,"project_name":309,"published":310,"publishedAt":311,"seo":312,"stem":313,"tags":314,"todo":307,"unpublished":310,"updatedAt":307,"__hash__":321},"pages/2026-06/2026-06-23/portfolio-highest-value-bug.md","ポートフォリオ最高値通知がブレるバグを直した話 - 同日再実行で高値が上書きされる罠",{"type":7,"value":8,"toc":293},"minimark",[9,13,17,20,23,31,44,59,62,65,72,83,86,100,103,106,116,181,194,203,206,209,241,244,247,250,253,270,273,276,289],[10,11,12],"h2",{"id":12},"何が起きたか",[14,15,16],"p",{},"毎朝LINEに「ポートフォリオの最高値」を投げる通知が、日によって違う数字を出してくる。本日の更新分は確かに最高値を取ったので分かりにくいが、過去ログを並べてみると、ある日は最高値Aを出し、その翌日には最高値Aより低いBが「最高値」として出ている。最高値が下がるはずがない。",[14,18,19],{},"LINEのトーク履歴をスクロールして並べた時点で「これはバグだ」と確信した。実装を自分で書いた覚えがあるが、しばらく触っていない領域なので、まず Claude Code に流れを追ってもらうことにした。",[10,21,22],{"id":22},"調査の方針",[14,24,25,26,30],{},"実装は ",[27,28,29],"code",{},"personal-notification-bots/rakuten-portfolio-tracker/update-portfolio.mjs"," の1ファイルに集約されている。1,600行ある中から、関連しそうな関数だけ狙い撃ちで読んでもらった。",[32,33,34,38,41],"ul",{},[35,36,37],"li",{},"履歴シート（Google Sheets）に当日のポートフォリオ評価額を upsert する処理",[35,39,40],{},"そこから「最高値」を計算して LINE メッセージに差し込む処理",[35,42,43],{},"同日に何度も再実行されたときの挙動",[14,45,46,47,50,51,54,55,58],{},"通知文を組み立てているのは ",[27,48,49],{},"buildPersonPortfolioNotification","、最高値を計算しているのは ",[27,52,53],{},"peakByPersonUpTo","、履歴を upsert しているのは ",[27,56,57],{},"upsertHistory"," の3つに切り分けられた。",[10,60,61],{"id":61},"原因の特定",[14,63,64],{},"最高値を計算する関数は、履歴シートを priceDate ごとに走査して各人の最大評価額を返す素直な実装になっていた。ロジック単体に問題は無い。怪しいのは、その入力となる「履歴シート側」の状態だった。",[14,66,67,68,71],{},"ここで罠だったのは Stooq の priceDate の挙動だ。Stooq は当日中に何度叩いても priceDate を「前営業日」のまま返してくる。つまり同じ日に2回スクリプトを走らせると、同じ ",[27,69,70],{},"(priceDate, person)"," のキーで履歴行が2回書かれることになる。",[14,73,74,75,77,78,82],{},"旧実装の ",[27,76,57],{}," は、既存行が見つかれば「常に上書き」していた。為替やマーケットの動きで2回目の評価額が1回目より下がっていると、シート上のその priceDate の値は ",[79,80,81],"strong",{},"下がった方の値で固定"," されてしまう。最高値計算はこのシートを真とするので、当然「最高値」も後の安い値に置き換わる。",[14,84,85],{},"具体的に2026-06-20のログを並べて再現した。",[32,87,88,91,97],{},[35,89,90],{},"1回目の実行: 評価額 X、シートに書き込まれる",[35,92,93,94],{},"2回目の実行: 評価額 X より少し低い Y、同じ priceDate を ",[79,95,96],{},"Y で上書き",[35,98,99],{},"翌日（2026-06-23）の通知: 最高値の候補から本来の X が消えており、Y を最高値として表示",[14,101,102],{},"LINEに届く「最高値」が日によってブレた正体はこれだった。",[10,104,105],{"id":105},"修正方針",[14,107,108,109,115],{},"原因が分かれば直し方は明確で、",[79,110,111,112,114],{},"同じ ",[27,113,70],{}," で再実行されたとき、新値が既存値より低ければ上書きしない"," ように upsert ロジックを変える。実装は1行追加するだけで済むが、雑に書くとテストできないので、判定だけを純粋関数として切り出した。",[117,118,123],"pre",{"className":119,"code":120,"language":121,"meta":122,"style":122},"language-js shiki shiki-themes vitesse-light vitesse-light","export const decideHistoryUpsertAction = ({ existingRow, newRow, lastDate }) => {\n  const [priceDate] = newRow;\n  if (lastDate && priceDate \u003C lastDate) return \"skip-old\";\n  if (!existingRow) return \"append\";\n  const existingValue = toNumber(existingRow[2]) ?? 0;\n  const newValue = toNumber(newRow[2]) ?? 0;\n  if (newValue \u003C= existingValue) return \"skip-lower\";\n  return \"update\";\n};\n","js","",[27,124,125,133,139,145,151,157,163,169,175],{"__ignoreMap":122},[126,127,130],"span",{"class":128,"line":129},"line",1,[126,131,132],{},"export const decideHistoryUpsertAction = ({ existingRow, newRow, lastDate }) => {\n",[126,134,136],{"class":128,"line":135},2,[126,137,138],{},"  const [priceDate] = newRow;\n",[126,140,142],{"class":128,"line":141},3,[126,143,144],{},"  if (lastDate && priceDate \u003C lastDate) return \"skip-old\";\n",[126,146,148],{"class":128,"line":147},4,[126,149,150],{},"  if (!existingRow) return \"append\";\n",[126,152,154],{"class":128,"line":153},5,[126,155,156],{},"  const existingValue = toNumber(existingRow[2]) ?? 0;\n",[126,158,160],{"class":128,"line":159},6,[126,161,162],{},"  const newValue = toNumber(newRow[2]) ?? 0;\n",[126,164,166],{"class":128,"line":165},7,[126,167,168],{},"  if (newValue \u003C= existingValue) return \"skip-lower\";\n",[126,170,172],{"class":128,"line":171},8,[126,173,174],{},"  return \"update\";\n",[126,176,178],{"class":128,"line":177},9,[126,179,180],{},"};\n",[14,182,183,186,187,189,190,193],{},[27,184,185],{},"skip-lower"," を追加したのが今回の修正点。",[27,188,57],{}," 本体はこの関数の戻り値を ",[27,191,192],{},"switch"," 的に分岐するだけにして、副作用（Google Sheets への書き込み）と判定ロジックを分離した。",[14,195,196,197,199,200,202],{},"もう一つ、最高値計算側の ",[27,198,53],{}," にも当日の priceDate を peak 候補から除外しないように手を入れた。当日除外を続けると、",[27,201,185],{}," で安い方を弾いても、当日の正しい高値が候補から消えてしまうケースが残るためだ。コメントに「2026-06-20 ログで実証」とまで書いてある。これは未来の自分が「なんでこのロジックなんだっけ」と忘れたときの保険。",[10,204,205],{"id":205},"テスト",[14,207,208],{},"純粋関数として切り出した目的は、当然テストを書くため。Claude Code にテストケースを足してもらった。",[32,210,211,231,236],{},[35,212,213,216,217,219,220,223,224,227,228],{},[27,214,215],{},"decideHistoryUpsertAction",": 新値が既存より低い → ",[27,218,185],{},"、高い → ",[27,221,222],{},"update","、新規 → ",[27,225,226],{},"append","、過去日付 → ",[27,229,230],{},"skip-old",[35,232,233,235],{},[27,234,53],{},": 当日の prior peak が保持される（同日再実行で安値を入れても最高値が消えない）",[35,237,238,240],{},[27,239,53],{},": 未来の priceDate は除外",[14,242,243],{},"既存の31件のテスト全てが通過することを確認して終わり。バグの再発防止が、判定ロジックそのものへのアサーションとしてコードに残った。",[10,245,246],{"id":246},"振り返り",[14,248,249],{},"LINEを毎朝眺めていれば異常には気づくが、原因の特定はログを並べて見ないと分からなかった。実装当時は「同じ priceDate なら常に上書きが安全」と思ってそう書いた記憶がある。Stooq の priceDate の挙動と、同日に複数回走るというワークフローを掛け合わせた時に初めて顔を出すバグだった。",[14,251,252],{},"教訓は2つ。",[32,254,255,261],{},[35,256,257,260],{},[79,258,259],{},"外部APIが返す日付フィールドは「時刻軸」とは独立に動くと疑う","。Stooq の priceDate は「データの基準日」であって「実行時刻」ではない",[35,262,263,269],{},[79,264,265,268],{},[27,266,267],{},"upsert"," という名前を付けた瞬間に「上書きしてよい根拠」を一度考える","。今回は「新値が高ければ上書き」が根拠で、それを満たさない再実行は弾くべきだった",[14,271,272],{},"Claude Code に流れを追わせて、原因を特定してもらい、純粋関数として切り出してもらい、テストを書いてもらった。自分でやったのは「LINEを見て違和感を拾った」「実装当時の思想を思い出して、何が抜けていたかを言語化した」の2つだけ。AIに任せて回す部分と、自分で違和感を拾う部分の役割分担が、今回もちょうど良かった。",[10,274,275],{"id":275},"関連ファイル",[32,277,278,283],{},[35,279,280,281],{},"実装本体: ",[27,282,29],{},[35,284,285,286],{},"テスト: ",[27,287,288],{},"personal-notification-bots/rakuten-portfolio-tracker/test/update-portfolio.test.mjs",[290,291,292],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":122,"searchDepth":135,"depth":135,"links":294},[295,296,297,298,299,300,301],{"id":12,"depth":135,"text":12},{"id":22,"depth":135,"text":22},{"id":61,"depth":135,"text":61},{"id":105,"depth":135,"text":105},{"id":205,"depth":135,"text":205},{"id":246,"depth":135,"text":246},{"id":275,"depth":135,"text":275},"dev","LINEに毎朝届くポートフォリオ最高値通知の値が日によって違う数字に化ける。Stooqの priceDate 固定と同日再実行で起きる履歴上書きが原因だった。純粋関数として切り出してテストごと直した記録。","md",{},true,null,"/portfolio-highest-value-bug","misc-dev",false,"2026-06-23T00:00:00.000Z",{"title":5,"description":303},"2026-06/2026-06-23/portfolio-highest-value-bug",[315,316,317,318,319,320],"personal-notification-bots","LINE通知","バグ修正","ポートフォリオ","Stooq","GoogleSheets","I7hnhf7APZf5eaiMP2IbBQd0DtqgELMrCfQSwzEb_dQ",[],"https://log.eurekapu.com/og/blog/portfolio-highest-value-bug.png?v=2026-06-23T00%3A00%3A00.000Z&title=%E3%83%9D%E3%83%BC%E3%83%88%E3%83%95%E3%82%A9%E3%83%AA%E3%82%AA%E6%9C%80%E9%AB%98%E5%80%A4%E9%80%9A%E7%9F%A5%E3%81%8C%E3%83%96%E3%83%AC%E3%82%8B%E3%83%90%E3%82%B0%E3%82%92%E7%9B%B4%E3%81%97%E3%81%9F%E8%A9%B1%20-%20%E5%90%8C%E6%97%A5%E5%86%8D%E5%AE%9F%E8%A1%8C%E3%81%A7%E9%AB%98%E5%80%A4%E3%81%8C%E4%B8%8A%E6%9B%B8%E3%81%8D%E3%81%95%E3%82%8C%E3%82%8B%E7%BD%A0&author=Kei%20Komatsu&sig=9547894c5acb4f66",1782364625889]