この記事は、以下のような方を想定しています。
- 週次・日次バッチ処理で履歴データの差分検出を自作している方
- PandasのDataFrameを使った蓄積型DBの設計に興味がある方
- FTPアップロードの自動化スクリプトを書いたことがある方
- Windows環境のPythonバッチで文字コードのトラブルに遭った方
前回の記事では、JPXのExcelを自動取得する部分のハマりポイントを紹介しました。 今回は、このシステムで一番苦労した「前回データとの差分がうまく取れない」という問題と、 それを解決するためにスナップショットJSON方式からPKL(Pandasのバイナリ形式)による累積DB方式へ 作り変えた経緯を紹介します。あわせて、運用中に気付いたFTPアップロード処理の落とし穴についても解説します。
最初の設計:スナップショットJSONだけで差分を取る方式
最初に作ったバージョンは非常にシンプルな設計でした。Excelを取得するたびに、その時点の銘柄コード一覧を
snapshot_YYYYMMDD_HHMMSS.jsonというファイル名でそのまま保存し、直前のスナップショットファイルと
コードの集合を比較するだけで「新規追加」「削除」を判定する、というものです。
最初の差分検出ロジック(簡略化)
def load_previous(path):
snapshots = sorted(DATA_DIR.glob("snapshot_*.json"), reverse=True)
if not snapshots:
return None
prev = json.loads(snapshots[0].read_text(encoding="utf-8"))
return pd.DataFrame(prev)
def detect_diff(current, previous):
curr_codes = set(current["code"])
prev_codes = set(previous["code"])
added = curr_codes - prev_codes
removed = prev_codes - curr_codes
return added, removed
仕組みとしてはこれだけで十分動くはずでした。実際、最初の数回の実行では狙った通りに「新規追加」「削除」が 検出でき、うまく動いているように見えました。
起きた問題:差分がうまく取れない
ところが運用を続けていくうちに、いくつかの問題が出てきました。
一つ目は、JPXのExcelが更新されない週の扱いです。毎日チェックして変化がなければ、 パイプライン側で「新しいExcelがないためStep2以降をスキップ」という早期リターンを入れていたのですが、 この設計だとその週はスナップショットも一切保存されません。履歴が「更新があった週」だけの歯抜け状態になり、 本来連続しているはずの改善期限までの残り日数の推移を後から追えなくなっていました。
二つ目は、スナップショットの保存件数を直近20件に絞っていたことによる影響です。 ある銘柄が一度改善期間を抜けてから数ヶ月後に再度該当した場合、過去いつ初めて該当したかという情報が 消えてしまい、「今回が初めての該当なのか、再該当なのか」を判別できなくなっていました。
三つ目は、コードの重複です。JPXのExcelは銘柄によって複数の基準(株主数・流通株式比率・売買代金など)に
同時に該当しているケースがあり、行の持ち方によっては同じコードが複数行に分かれて出現することがありました。
スナップショットのcodesリストを単純に保存していたため、件数のカウントが実際の銘柄数より
多くなる、という地味な不具合も発生していました。
気づきのポイント
「直近の数件だけを軽量に持つログ」と「長期的に分析できる履歴データベース」は、本質的に別の役割を 持つものでした。差分検出のためだけに設計したスナップショットJSONを、知らないうちに「銘柄ごとの履歴管理」 という別の用途にも使おうとしてしまったのが、混乱の根本原因だったと感じています。
解決策:PKLによる累積DB化
そこで設計を見直し、取得したExcelの内容を毎回PKL(Pandasのバイナリシリアライズ形式)に 追記・蓄積していく方式に変更しました。スナップショットJSONは「直近20件だけを持つ 軽量な変化確認ログ」という役割に限定し、銘柄の本格的な履歴管理や基準日の計算は、すべてPKLに蓄積された 全期間のDataFrameを使う、という役割分担にしています。
PKLを組み込んだ最終的な処理の流れは下図のようなイメージです。
PKL読み込み後のデータ変換処理(抜粋)
DATE_COLS = ['株主数','流通株式比率','流通株式時価総額','売買代金','時価総額','純資産の額']
for col in DATE_COLS:
df[col] = pd.to_datetime(df[col], errors='coerce')
df['earliest_deadline'] = df[DATE_COLS].min(axis=1)
df['criteria'] = df.apply(
lambda r: '・'.join([c for c in DATE_COLS if pd.notna(r[c])]), axis=1)
PKLには取得した日付ごとのレコードがそのまま積み上がっていくため、ある銘柄がいつ最初に改善期間入りしたか、 どの基準で何度該当を繰り返しているか、といった情報を後からいつでも遡って集計できるようになりました。 スナップショットJSONの方は、PKLを更新した結果として得られる「今回時点のコード一覧」を保存するだけの ログとして使い、SNS自動投稿のための「前回との増減」だけを軽く確認する用途に絞っています。
役割を分けたことで整理できたデータの位置づけ
| データ | 役割 | 保持期間 |
|---|---|---|
| companies_improvement_period_jp.pkl | 全期間の累積履歴。表示・分析の正本データ | 無期限(増え続ける) |
| jpx_snapshots.json | 直近の銘柄コード一覧。SNS投稿用の増減チェックのみ | 直近20件のみ |
合わせて、コードの重複についてはdict.fromkeys()を使ってリストの順序を保ったまま重複を
除去する処理を入れ、件数が実際の銘柄数とズレないようにしました。地味な一文の修正ですが、これを入れる前は
実際の銘柄数より数件多い件数がサイト上に表示され続けており、気付くまでに何週か要してしまいました。
PKLへ履歴を蓄積する仕組みに変更したことで、データは毎日の実行結果を自動的に積み重ねていく構成になりました。 実際の保存イメージは下図のようになります。
もうひとつの罠:FTPアップロードでindex.htmlを上書きし続ける処理
差分検出の問題が片付いた後、別の場所で気付いたのがFTPアップロード処理の挙動です。
生成したHTMLをレンタルサーバーにアップロードする際、ファイル名そのままの名前と、トップページ用の
index.htmlの両方に同じ内容を書き込みたかったため、以下のような処理にしていました。
実際のアップロード処理
for local_path in FTP_UPLOAD_FILES:
remote_name = local_path.name
with open(local_path, "rb") as f:
ftp.storbinary(f"STOR {remote_name}", f)
f.seek(0)
ftp.storbinary(f"STOR {'index.html'}", f)
HTMLファイルが1つだけの間は問題なく動いていたのですが、将来sitemap.xmlやrobots.txtなど
他のファイルも一緒にアップロードしたくなり、FTP_UPLOAD_FILESのリストに追加しようとした際に、
このコードの危うさに気付きました。ループの中で対象ファイルが何であっても無条件に
index.htmlへ同じ内容を書き込むため、複数ファイルをリストに入れると、最後に処理された
ファイルの内容でindex.htmlが毎回上書きされてしまう構造になっていたのです。
幸い他のファイルを追加する前に気付けましたが、対策として設定ファイル側のコメントに
「HTMLファイル以外を追加する場合はindex.htmlへの複製処理を別関数に切り出すこと」という
注意書きを残し、現状はアップロード対象を1つのHTMLファイルのみに限定して運用しています。
気づきのポイント
ループの中で「ループ変数に関係なく常に同じ副作用(index.htmlへの書き込み)を起こす」処理を書くと、 ループの対象が増えたときに想定外の挙動を生みやすくなります。汎用的なループ処理と、特定の1ファイルに 紐づく特別な処理は、最初から関数として分離しておくべきだったと反省しています。
FTP処理の完成後はWindowsタスクスケジューラから毎日自動実行する構成にしました。 東証側のExcel更新は基本的に週1回ですが、毎日処理することで更新日に自動で取り込みが行われるようにしています。
文字コードでもハマった話
最後にもう一つ、運用中に何度か遭遇したのがWindows環境での文字コードのトラブルです。
パイプライン全体を統括するrun_pipeline.pyでは、各ステップをsubprocess.run()で
別プロセスとして呼び出していますが、その際の標準出力の取得にはencoding="cp932"を
指定しています。これはWindows日本語環境のデフォルトであるShift-JIS系(cp932)で出力されることを
前提にした設定です。一方でSNS自動投稿を行うpost_sns.pyを呼び出す箇所だけは
encoding="utf-8"を指定しています。投稿先のAPIがUTF-8前提のライブラリを使っているためです。
同じパイプラインの中でも呼び出し先によって文字コードの前提が違う、という状態は気付きにくく、
最初はログファイルの一部だけが文字化けする原因が分からずしばらく調査することになりました。
対処の方針
呼び出し先のスクリプトごとに「そのスクリプトが標準出力に何の文字コードで書き出しているか」を
コメントとして明記し、subprocess.run()のencoding引数をその都度個別に
指定するようにしました。パイプライン全体を1つの文字コードに統一できれば理想的ですが、
既存スクリプトの書き換えコストとのバランスを取り、現状はこの個別対応で運用しています。
今回の②では、差分検出の設計をPKL累積DB方式に作り変えた経緯と、FTPアップロード・文字コードという 2つの落とし穴を紹介しました。自動化パイプラインは「動いているように見えて実は一部が壊れている」状態に 気付きにくいという点で、こうした地味な検証の積み重ねが結局は一番重要だと実感しています。
FAQ
週次更新データの差分検出で履歴が欠落するのはなぜですか?
「変化がなかった週はスキップする」という設計にすると、その週のスナップショットが保存されないため、 保存件数の上限を超えた古いスナップショットが削除された際に履歴が途切れることがあります。 直近の数件だけを比較する設計では、長期的な傾向を追えなくなります。
スナップショットJSON方式とPKL累積DB方式の違いは何ですか?
スナップショットJSON方式は直近の数件だけを保持する軽量なログです。一方PKL累積DB方式は、 取得した全期間のレコードをDataFrameとして蓄積するため、銘柄ごとの基準日の変化や再登場のパターンまで 後から分析できます。
FTPで複数ファイルをアップロードする際にindex.htmlを重複生成するとどうなりますか?
アップロード対象ファイルが複数ある場合、ループのたびにindex.htmlへ同じ内容を書き込む処理だと、 最後に処理したファイルの内容で常にindex.htmlが上書きされます。複数のHTMLファイルをアップロードリストに 含めると、意図しないファイルがトップページになってしまう恐れがあります。