この記事は、以下のような方を想定しています。

  • 前回データとの差分検出処理を自作している方
  • Pandasで「関数内の変更が反映されない」現象にハマった方
  • 日次バッチ処理の設計・デバッグに興味がある方

前回の記事では、日証金とJPXのデータを取得してマージするところまでを紹介しました。 今回は、そのマージ済みデータから「新規・継続・変化・解除」を判定する差分検出ロジックの設計と、 実際に運用してみて発覚した「新規ばかりになる」という大きなバグの原因と解決方法を紹介します。

状態管理の基本設計

このサイトでは、各銘柄を以下の4状態のいずれかに分類しています。

状態 判定条件
新規 前回の処理にコードが存在しなかった
継続 前回にも存在し、規制フラグ(売禁・注意喚起・増担)に変化なし
変化 前回にも存在し、規制フラグが1つ以上変わった
解除 前回は存在したが、今回のデータには存在しない

等式で表すと次のようになります。これがサイト上のKPI表示の基本になっています。

  • 総銘柄数 = 新規 + 継続 + 変化
  • 変化差分 = 新規 + 解除

最初に出会ったバグ:状態が反映されない

最初に実装したとき、状態判定の関数は受け取ったDataFrameをdf.copy()してから状態列を追加する、 という素直な実装にしていました。

当初のコード(簡略化)

def detect_diff(df_today):
  df_today = df_today.copy()
  df_today["状態"] = ... ← ここで更新
  return df_new, df_changed, df_released

呼び出し側ではdetect_diff(merged)のように呼んでいたのですが、 ランキングCSVを作る際に元のmergedを使っていたため、状態列が「継続」のまま 一切更新されていないという現象が起きていました。

これはPythonの仕様として当然の動作です。関数内でcopy()した時点で別オブジェクトになるため、 その後の変更は呼び出し元の変数には影響しません。関数の戻り値として更新後のDataFrameを返し、 呼び出し元でその戻り値を使うように修正する必要がありました。

2つ目のバグ:新規が456件も出る

1つ目のバグを直したあと、いよいいよ正しく動くはずだったのですが、実際にサイトに表示されたKPIカードを見ると、 総銘柄数464件に対して新規が456件という、明らかにおかしい数字が出ていました。継続はほぼ0件です。

「2回目以降の実行なら、ほとんどの銘柄は前回と同じはずなので継続になるべきだ」という前提に対して、 実際の結果は真逆でした。ここから本格的なデバッグが始まりました。

原因:比較キーに「通知日」を使っていた

差分検出のロジックでは、前回データを取得するために以下のような処理を入れていました。

問題のあったコード

latest_date = df_old["日付"].max()
df_prev = df_old[df_old["日付"] == latest_date]

この「日付」列は、日証金サイトに記載されている銘柄ごとの措置通知日でした。 銘柄Aは2022年7月に通知、銘柄Bは2025年7月に通知、というように、銘柄ごとに全く異なる日付が入っています。

df_old["日付"].max()を実行すると、「全銘柄の中でもっとも新しい通知日を持つ1件」だけが 抽出されてしまいます。つまり前回データのほとんどの銘柄がdf_prevに含まれず、 今回データのコードと比較した際に「前回存在しない」と判定され、軒並み「新規」になっていたのです。

気づきのポイント

「日付」という名前の列が複数の意味を持っていたのが根本原因でした。 「内容に付随する日付(通知日)」と「処理を実行した日(バッチ実行日)」は本質的に別の情報であり、 同じ列で扱おうとすると差分比較のような「時系列で前後を比較する」処理で簡単に事故が起きます。

解決策:「処理日」列を導入する

解決策として、history.csv(履歴データ)に「処理日」という新しい列を追加しました。 この列には、銘柄の内容にかかわらず、main.pyを実行した日付を全レコード共通で書き込みます。

列名 意味 差分比較に使うか
日付 日証金サイト記載の措置通知日(銘柄ごとに異なる) 使わない
処理日 main.py実行日(全銘柄で共通) 使う

比較ロジックを「処理日列の最大値」を前回実行日として扱うように変更したことで、 前回実行時の全銘柄が正しくprev_codesに含まれるようになり、 コードの存在/非存在だけで新規・継続・解除を正確に判定できるようになりました。

同日再実行への対応

もう一点考慮したのが「同じ日にバッチを2回実行した場合」の挙動です。 もし処理日の最大値が今日の日付と同じだった場合、それは「今日すでに1回実行済み」ということになるため、 比較対象を「前々回(その前に実行した日)」にずらす分岐を入れています。

さらに、history.csvへの保存時には「今日の処理日のレコードを一度削除してから、新しい今日のデータを追記する」 という形にしました。これにより、何度再実行しても重複データが増えず、結果が安定するようになっています。

動作確認の方法

修正後は、簡易的なテストデータを使ってシミュレーションを行いました。 「1日目に4銘柄が新規登録される」「2日目に1銘柄が解除され、1銘柄が新規に追加され、1銘柄のフラグが変化する」 というシナリオを作り、新規1件・継続2件・変化1件・解除1件という想定通りの結果が出ることを確認しています。

次回の記事では、このバックエンドのデータを実際にWebサイト上で表示する、フロントエンド側のUI設計について 紹介します。KPIカード・タブ・フィルタチップという3系統のフィルタを「お互いに干渉させない」ように 設計した工夫や、スマホ対応で発生したレイアウト崩れの修正なども取り上げます。

FAQ

差分検出でデータが毎回「新規」になってしまうのはなぜですか?

比較に使う日付列が、レコードごとに異なる「内容の日付」になっていると、前回データの大半が比較対象から外れてしまい、 全件「新規」と誤判定されることがあります。比較には「処理を実行した日」のような共通キーを使う必要があります。

PandasのDataFrameをコピーして関数内で更新しても呼び出し元に反映されないのはなぜですか?

df.copy()をしてから列を追加・変更した場合、それは新しいオブジェクトへの変更になるため、 呼び出し元の元のDataFrameには反映されません。更新後のDataFrameを戻り値として返し、呼び出し元で受け取る必要があります。

差分検出処理の同日再実行はどう扱うべきですか?

同じ処理日で再実行された場合は、直前の処理日のデータを今日のデータで上書きし、その前の処理日と比較する設計にすることで、 何度実行しても結果が安定します。