※本記事には広告・アフィリエイトリンクが含まれます。収益はサイト運営・検証費用に充てています。掲載コードは学習・検証目的です。
先に結論です。複数銘柄を正規化して重ねたグラフと、期間全体の相関ヒートマップだけで「分散できている」と判断すると、暴落で全部同時に下がります。相関は固定値ではありません。平常時に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_stressがavg_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_stabilityとcorr_in_drawdownで相関の壊れ方を見る。最後にdiversification_reportのratioを記録する。この4つの数字を持っていれば、銘柄を足す判断が感覚から数値に変わります。
データ取得ツールの選び方はPython株価データ取得ツール徹底比較、リターンやボラの計算そのものを固めたい場合は株分析を学ぶ本3選を参照してください。
【投資免責事項】本記事で紹介するコードおよび分析結果は情報提供を目的としたものであり、特定の銘柄への投資を推奨するものではありません。投資判断はご自身の責任で行ってください。

