Pythonで複数銘柄の株価を比較|正規化と相関分析の実装

複数銘柄の株価を並べて比較する Python実装・コード

※本記事には広告・アフィリエイトリンクが含まれます。収益はサイト運営・検証費用に充てています。掲載コードは学習・検証目的です。

先に結論です。複数銘柄を正規化して重ねたグラフと、期間全体の相関ヒートマップだけで「分散できている」と判断すると、暴落で全部同時に下がります。相関は固定値ではありません。平常時に0.3だったペアが、急落局面で0.8まで上がるのは普通に起きます。

やること自体は簡単です。yfinanceに銘柄リストを渡し、基準日100で正規化し、日次リターンの相関を取る。ここまでは10行です。問題は、その数字がいつの、どんな相場の相関なのかを誰も確認しないことです。

本記事では、基本の比較コードに加えて、(1) 基準日を変えると順位が入れ替わること、(2) ローリング相関で関係が壊れる瞬間を可視化すること、(3) 分散効果を「加重平均ボラとの差」で数値化すること、の3点を実装します。

📘 外部参考DataFrame.corr(pandas公式)yfinance(GitHub)

複数銘柄をまとめて取得する

yfinanceは銘柄リストを渡すと一括で取得します。ここで最初に詰まるのが列構造です。バージョンによってMultiIndexになり、df["Close"]が期待どおりに動きません。取得直後に必ず平坦化します。

import numpy as np
import pandas as pd
import yfinance as yf

TICKERS = {
    "7203.T": "トヨタ",
    "9984.T": "ソフトバンクG",
    "6758.T": "ソニーG",
    "8306.T": "三菱UFJ",
}

def load_closes(tickers: list[str], start: str = "2019-01-01") -> pd.DataFrame:
    raw = yf.download(
        tickers,
        start=start,
        auto_adjust=True,
        progress=False,
        group_by="column",
    )
    if isinstance(raw.columns, pd.MultiIndex):
        close = raw.xs("Close", axis=1, level=0)
    else:
        close = raw[["Close"]]
        close.columns = tickers
    close.index = pd.to_datetime(close.index).tz_localize(None)
    # 全銘柄が揃っている日だけ残す(片方だけ休場の日を除外)
    return close.dropna(how="any").sort_index()

close = load_closes(list(TICKERS))
close = close.rename(columns=TICKERS)
print(close.tail(3))
print("営業日数", len(close))

dropna(how="any")を入れているのは、相関計算のためです。片方だけ欠損した日を残すと、ペアごとに使う日数が変わり、比較にならなくなります。日本株どうしなら差は小さいですが、米国指数や為替を混ぜると祝日のズレが効いてきます。

auto_adjust=Trueにしているのも重要です。未調整の価格で長期比較すると、分割した銘柄が突然半値になり、パフォーマンスが実態とかけ離れます。

正規化は「基準日」で結論が変わる

基準日の終値を100として揃えるのが定番です。ただし、この手法は基準日にどの銘柄が安かったかに強く依存します。同じ4銘柄でも、開始日を1年ずらすと順位が入れ替わります。

def normalize(close: pd.DataFrame, base: str | None = None) -> pd.DataFrame:
    df = close if base is None else close.loc[base:]
    return df / df.iloc[0] * 100

def rank_by_period(close: pd.DataFrame, starts: list[str]) -> pd.DataFrame:
    rows = []
    for s in starts:
        sub = close.loc[s:]
        if sub.empty:
            continue
        perf = (sub.iloc[-1] / sub.iloc[0] - 1) * 100
        row = {"start": s}
        for name, v in perf.sort_values(ascending=False).items():
            row[name] = round(float(v), 1)
        rows.append(row)
    return pd.DataFrame(rows)

print(rank_by_period(close, ["2019-01-01", "2021-01-01", "2023-01-01"]))

3行を見比べると、どの銘柄が「強い」かは期間で変わります。ブログやSNSでよく見る「この5銘柄を比較した結果」は、開始日を選んだ時点で結論が半分決まっています。自分で検証するときは、必ず複数の開始日で出してください。

グラフに描く

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

def plot_normalized(close: pd.DataFrame, path: str = "normalized.png") -> str:
    norm = normalize(close)
    fig, ax = plt.subplots(figsize=(12, 6))
    for col in norm.columns:
        ax.plot(norm.index, norm[col], label=col, linewidth=1.2)
    ax.axhline(100, color="gray", linestyle="--", alpha=0.6)
    ax.set_ylabel("基準日=100")
    ax.legend(loc="upper left")
    ax.grid(alpha=0.3)
    fig.tight_layout()
    fig.savefig(path, dpi=120)
    plt.close(fig)
    return path

日本語ラベルが豆腐(□)になる場合は、matplotlibにフォントを指定してください。Windowsならplt.rcParams["font.family"] = "Meiryo"、Colabならjapanize-matplotlibの導入が手早いです。

相関は「期間全体の1枚」で見ない

ここからが本題です。まず定番のヒートマップを作りますが、これは出発点であって結論ではありません。

def corr_matrix(close: pd.DataFrame) -> pd.DataFrame:
    returns = close.pct_change().dropna()
    return returns.corr().round(3)

print(corr_matrix(close))

価格そのものではなく日次リターンで相関を取ります。価格で相関を取ると、どちらも右肩上がりというだけで0.9を超えます。これは「同じように上がった」であって「同じ日に同じ方向へ動く」ではありません。見せかけの相関を避けるための基本です。

ローリング相関で関係の崩れを見る

def rolling_corr(close: pd.DataFrame, a: str, b: str, window: int = 60) -> pd.Series:
    ret = close.pct_change().dropna()
    return ret[a].rolling(window).corr(ret[b]).dropna()

def corr_stability(close: pd.DataFrame, window: int = 60) -> pd.DataFrame:
    cols = list(close.columns)
    rows = []
    for i in range(len(cols)):
        for j in range(i + 1, len(cols)):
            rc = rolling_corr(close, cols[i], cols[j], window)
            rows.append({
                "pair": f"{cols[i]} - {cols[j]}",
                "full_period": round(float(close.pct_change().dropna()[cols[i]]
                                           .corr(close.pct_change().dropna()[cols[j]])), 3),
                "rolling_min": round(float(rc.min()), 3),
                "rolling_max": round(float(rc.max()), 3),
                "range": round(float(rc.max() - rc.min()), 3),
            })
    return pd.DataFrame(rows).sort_values("range", ascending=False)

print(corr_stability(close))

rangeが大きいペアほど、期間全体の相関値は当てになりません。full_periodが0.4でも、rolling_maxが0.85なら、荒れた局面では一緒に落ちるということです。分散のつもりで組んだ2銘柄が、一番助けてほしい日に同じ方向へ動く。これが相関を1枚のヒートマップで判断する危険です。

相関を過信して痛い目を見た話は「相関係数0.8」を信じていた僕が学び直した落とし穴にもまとめています。数値の出し方より、数値が壊れる条件を知る方が実務では役に立ちます。

下落局面だけの相関を切り出す

もっと直接的に、市場が下げた日だけで相関を取り直します。ここでは4銘柄の平均リターンを市場の代理とします。

def corr_in_drawdown(close: pd.DataFrame, threshold: float = -0.01) -> dict:
    ret = close.pct_change().dropna()
    market = ret.mean(axis=1)
    calm = ret[market > threshold]
    stress = ret[market <= threshold]

    def avg_offdiag(c: pd.DataFrame) -> float:
        v = c.values
        n = v.shape[0]
        mask = ~np.eye(n, dtype=bool)
        return float(v[mask].mean())

    return {
        "calm_days": int(len(calm)),
        "stress_days": int(len(stress)),
        "avg_corr_calm": round(avg_offdiag(calm.corr()), 3),
        "avg_corr_stress": round(avg_offdiag(stress.corr()), 3),
    }

print(corr_in_drawdown(close))

多くの組み合わせで、avg_corr_stressavg_corr_calmを上回ります。平常時は別々に動き、下げる日は揃って下げる。これを知らずに「相関0.3だから分散できている」と考えると、想定より深いドローダウンを食らいます。ドローダウンの測り方は最大ドローダウンをPythonで計算する記事を参照してください。

分散効果を数値にする

「相関が低いと分散が効く」を、感覚ではなく差分で出します。個別銘柄のボラティリティを保有比率で加重平均したものと、実際のポートフォリオのボラティリティを比べます。差が大きいほど、分散が効いています。

TRADING_DAYS = 245  # 日本株の概算。米国株なら252

def diversification_report(close: pd.DataFrame, weights=None) -> dict:
    ret = close.pct_change().dropna()
    n = ret.shape[1]
    w = np.repeat(1 / n, n) if weights is None else np.asarray(weights, dtype=float)
    w = w / w.sum()

    port = ret.dot(w)
    port_vol = float(port.std(ddof=1) * np.sqrt(TRADING_DAYS))
    each_vol = ret.std(ddof=1) * np.sqrt(TRADING_DAYS)
    weighted_avg_vol = float((each_vol.values * w).sum())

    cum = (1 + port).cumprod()
    dd = cum / cum.cummax() - 1

    return {
        "portfolio_vol": round(port_vol, 4),
        "weighted_avg_vol": round(weighted_avg_vol, 4),
        "diversification_gain": round(weighted_avg_vol - port_vol, 4),
        "ratio": round(port_vol / weighted_avg_vol, 3),
        "portfolio_total": round(float(cum.iloc[-1] - 1), 4),
        "portfolio_max_dd": round(float(dd.min()), 4),
    }

print(diversification_report(close))

ratioが1.0に近いほど、分散していないのと同じです。0.8なら、単純加重平均より2割ボラが下がっているという意味になります。銘柄数を増やしても同業種ばかりなら、この比率は下がりません。

指標意味目安
portfolio_vol実際のポートフォリオ年率ボラ低いほど安定
weighted_avg_vol各銘柄ボラの加重平均分散ゼロならこの値になる
ratio前者÷後者0.9超なら分散できていない
avg_corr_stress下落日の平均相関0.8超なら暴落時は1銘柄と同じ
range(ローリング)相関の振れ幅0.5超なら期間全体の値は使わない

銘柄を入れ替えて比較する

def compare_baskets(baskets: dict[str, list[str]], start: str = "2019-01-01") -> pd.DataFrame:
    rows = []
    for name, tickers in baskets.items():
        px = load_closes(tickers, start=start)
        rep = diversification_report(px)
        rep["basket"] = name
        rows.append(rep)
    return pd.DataFrame(rows).set_index("basket")

baskets = {
    "自動車集中": ["7203.T", "7267.T", "7201.T"],
    "業種分散": ["7203.T", "8306.T", "4502.T"],
    "内外分散": ["7203.T", "8306.T", "SPY"],
}
print(compare_baskets(baskets))

自動車3社のようなバスケットはratioが0.95前後に張り付きます。銘柄数を増やしても、同じ材料で動くものを並べているだけだからです。SPYのような海外ETFを混ぜると営業日が減る点にも注意してください(dropna(how="any")で共通日だけが残ります)。

よくある間違い

  • 価格の相関を見る。右肩上がりどうしなら0.9を超えます。必ず日次リターンで取ります。
  • 年率換算に252を使う。日本株は年間およそ245営業日です。米国株と混ぜるなら基準を明示します。
  • 基準日を1つしか試さない。開始日で順位が変わります。最低3パターンは出します。
  • 欠損を前方補完する。ffillで埋めると、動いていない日の相関が水増しされます。共通日で揃える方が安全です。
  • 相関が低い=安全と考える。下落局面の相関を別に測らないと意味がありません。

まとめ:ヒートマップは出発点

複数銘柄の比較は、正規化グラフと相関ヒートマップで終わりがちです。実際に判断材料になるのは、基準日を変えた順位、ローリング相関の振れ幅、下落日だけの相関、そして加重平均ボラとの差です。

今日試すなら順序はこうです。load_closesで共通営業日を揃える。rank_by_periodで開始日依存を確認する。corr_stabilitycorr_in_drawdownで相関の壊れ方を見る。最後にdiversification_reportratioを記録する。この4つの数字を持っていれば、銘柄を足す判断が感覚から数値に変わります。

データ取得ツールの選び方はPython株価データ取得ツール徹底比較、リターンやボラの計算そのものを固めたい場合は株分析を学ぶ本3選を参照してください。


【投資免責事項】本記事で紹介するコードおよび分析結果は情報提供を目的としたものであり、特定の銘柄への投資を推奨するものではありません。投資判断はご自身の責任で行ってください。

タイトルとURLをコピーしました