1つの提供元だけで完結する分析は稀です。複数の提供元からデータを取ると、必ず突き合わせが必要になります。価格はA社から、財務はB社から、業種はC社から。これらを1つの表にまとめるとき、何を鍵にするかが問題になります。
から返ってくる項目の中で、いちばん鍵になりそうに見えるのが銘柄コードです。ところがは一意ではありません。同じ会社が複数の市場に上場していれば市場ごとに別のコードを持ちますし、上場廃止されたコードは、しばらくすると別の会社に割り当てられます。
この性質を知らないまま素直に結合すると、別の会社のデータが静かに混ざります。しかも混ざるのは全体のごく一部です。例外は出ず、行数も合っているので、気づく手がかりがありません。この回では、どこで混ざるのかと、どう防ぐかを扱います。
コードが指すものがずれる3つの場面
混ざり方には型があります。どれも「コードは同じだが、指している対象が違う」という形です。
- 市場が違う — 同じ会社でも、国内と海外で別のコードを持つ。逆に、別の市場の同じコードが別会社を指すこともある
- 時期が違う — 廃止されたコードの再利用。過去のデータと現在のデータで、同じコードが別会社を指す
- 体系が違う — 株式・投資信託・債券で識別子の体系が別。数字だけを見ると衝突しうる
3つ目は、株式以外を混ぜたときに起こります。投資信託や債券は別の識別子体系を持つので、数字だけを取り出して同じ列に入れると衝突しえます。識別子の種類も一緒に持つことで防げます。とくに2つ目は、長期の系列を扱うときに効いてきます。10年前のデータと今のデータを同じコードで結合すると、その間に廃止と再割当があった銘柄だけが入れ替わります。全体の中では少数なので、集計してもすぐには分かりません。
コードに市場と時期を添える
対策の基本は、コードだけを鍵にしないことです。市場を表す情報と、いつ時点のものかを一緒に持たせます。この3つ組であれば、上の3つの場面のうち2つは防げます。
from dataclasses import dataclassfrom datetime import date
@dataclass(frozen=True)class SecurityKey: """コード単独を鍵にしない。市場と時点を必ず添える。"""
code: str # 銘柄コード(提供元の表記のまま) market: str # 市場を表す識別子 as_of: date # そのコードがこの会社を指していた時点
def merge_key(self) -> tuple[str, str]: """同じ時点どうしを結合するときの鍵。""" return (self.code, self.market)
def assert_unique(rows: list[SecurityKey]) -> None: """同じ (コード, 市場) が複数の時点で別会社を指していないか確かめる。""" seen: dict[tuple[str, str], set[date]] = {} for r in rows: seen.setdefault(r.merge_key(), set()).add(r.as_of) dup = {k: v for k, v in seen.items() if len(v) > 1} if dup: raise ValueError(f"同じコードが複数時点に存在する: {list(dup)[:5]}")ISIN のように、国と発行体を含んだ体系の識別子があります。これらは再利用されにくく、市場をまたいでも一意性が高いです。提供元が対応していれば、こちらを主鍵にするほうが安全です。ただし全部の提供元が持っているとは限りません。
結合したあとに必ず数える
配信元の側で識別子の体系が変わることもあります。これはの一種で、こちらの対応表が黙って古くなります。どれだけ気をつけても、突き合わせは失敗します。だから結合したあとに件数を数えるのが最後の砦になります。結合前と結合後で件数が変われば、何かが起きています。
の結合は、既定では重複があっても黙って行を増やします。増えていれば、片方に重複があって行が掛け算になっています。減っていれば、鍵が一致しなかった行が落ちています。どちらも例外は出ませんので、自分で数えるしかありません。
import pandas as pd
def safe_merge(left: pd.DataFrame, right: pd.DataFrame, on: list[str]) -> pd.DataFrame: """1 対 1 の結合を前提に、崩れたら止める。""" # 右側の鍵が重複していれば、結合で行が増える dup = right.duplicated(subset=on).sum() if dup: raise ValueError(f"右側に鍵の重複が {dup} 件ある。1 対 1 で結合できない")
merged = left.merge(right, on=on, how="left", indicator=True)
if len(merged) != len(left): raise ValueError(f"件数が変わった: {len(left)} -> {len(merged)}")
unmatched = (merged["_merge"] == "left_only").sum() if unmatched: rate = unmatched / len(left) print(f"警告: 一致しなかった行が {unmatched} 件({rate:.1%})")
return merged.drop(columns=["_merge"])結合の方向も意識してください。左を残す結合なら左の件数は変わりませんが、内側だけを残す結合だと一致しなかった行が消えます。件数を数えていれば、どちらの場合も気づけます。一致しなかった行を、黙って落とさないでください。割合を必ず表示するか、上限を決めて超えたら止めるようにします。1%なら許容できても、30%なら鍵の選び方が間違っています。
提供元をまたぐときは、対応表を自分で持つのが結局いちばん確実です。A社のコードとB社のコードを並べた表を作り、更新があれば手で直す。自動化したくなりますが、突き合わせの誤りは自動化では減りません。
対応表そのものもに置いて、いつ誰が直したかを残しておいてください。間違った対応を1行入れると、その銘柄のデータが恒久的に混ざります。履歴が無いと、いつから間違っていたのかも分からなくなります。
会社名や住所のような文字列での突き合わせについては 前処理の現場 で扱っています。次回は、市場データの時刻の問題をまとめます。
突き合わせは、うまくいっているときは誰も見ません。壊れたときだけ表に出ます。だからこそ、件数を数える処理を最初から入れておく価値があります。後から足そうとすると、いつから壊れていたのかを調べる作業が先に来ます。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。