相関係数の落とし穴|SOXと日本半導体株をPythonで検証

基礎知識・戦略

今朝チャートを見て「あれ?」と思いました。米国のSOX(半導体指数)は前営業日に反発して上がっているのに、日経平均の半導体関連(キオクシア、東京エレクトロンなど)はまさかの急落。以前、僕は「日本の半導体関連株とSOXって相関係数0.8くらいあるから、SOXが上がれば日本株も大体つれ高するはず」とアテにしてポジションを取っていた時期があったので、「あの相関係数って、どこまで信じてよかったんだっけ」と気になり、調べ直すことにしました。

結論から言うと、相関係数は便利な指標である一方、計算期間や相場環境が変わるとあっさり裏切ってくる、わりと繊細な数字でした。しかも僕の場合、それ以前に時差の扱いを間違えて相関を計算していたことが分かりました。この記事では、その4つの落とし穴を、Pythonのコードで1つずつ確かめていきます。

相関係数(ピアソンの積率相関係数)のおさらい

2つの銘柄のリターンを x, y としたとき、相関係数 r は次の式で求められます。

r = Σ (xᵢ − x̄)(yᵢ − ȳ) / √( Σ(xᵢ − x̄)² × Σ(yᵢ − ȳ)² )

r は −1〜+1の範囲を取り、+1に近いほど「同じ方向に動きやすい」、−1に近いほど「逆方向に動きやすい」ことを意味します。0.8といえば「かなり強い正の相関」で、これを見て僕は「日本の半導体株はSOXについていく」と思い込んでいたわけです。

落とし穴①:時差を無視して同じ日で並べている

これが僕の最大のミスでした。SOXの「7月7日」と東京エレクトロンの「7月7日」は、同じ日ではありません。米国市場が7月7日に引けるのは、日本時間の7月8日の朝5時です。つまり、日本株が7月7日に反応しているのは、SOXの7月6日の値動きです。

yfinanceでそのままDataFrameにすると、日付インデックスで自動的に揃えられます。何も考えないと「同日同士」で相関を取ることになり、実際の反応関係とは1日ズレます。

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

def load_close(ticker: str, period: str = "3y") -> pd.Series:
    df = yf.download(ticker, period=period, auto_adjust=True, progress=False)
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)
    s = df["Close"].dropna()
    s.index = pd.to_datetime(s.index).tz_localize(None)
    return s.sort_index()

tel = load_close("8035.T")    # 東京エレクトロン
soxx = load_close("SOXX")     # SOX連動ETF

px = pd.DataFrame({"TEL": tel, "SOXX": soxx}).dropna()
ret = px.pct_change().dropna()

def lag_scan(ret: pd.DataFrame, a: str = "TEL", b: str = "SOXX", lags=range(-3, 4)) -> pd.DataFrame:
    rows = []
    for k in lags:
        # k=1 は「b の k 日前のリターン」と a の相関
        r = ret[a].corr(ret[b].shift(k))
        rows.append({"lag": k, "corr": round(float(r), 3)})
    return pd.DataFrame(rows)

print(lag_scan(ret))

lag=0よりlag=1の方が高い相関になるはずです。「SOXXの1日前のリターン」が「TELの今日のリターン」を説明する、という構造になっているからです。ここを揃えずに0.8だ0.6だと議論しても、そもそも比べているものが違います。

lag意味解釈
0同じ日付どうし時差を無視。実は日本が先行して見える
1SOXXの前日 → TELの当日本来見たい関係。ここが最大になりやすい
-1TELの前日 → SOXXの当日逆方向。ここが高いなら因果の解釈を疑う

落とし穴②:計算期間でまったく違う数字になる

相関係数は「どの期間のデータを使ったか」に強く依存します。両方の指数が上昇トレンドだった半年間だけを切り取れば相関は高く出ますが、片方だけが個別要因で動く局面を含めると、途端に相関は崩れます。

def rolling_corr(ret: pd.DataFrame, window: int = 60, lag: int = 1) -> pd.Series:
    return ret["TEL"].rolling(window).corr(ret["SOXX"].shift(lag)).dropna()

rc = rolling_corr(ret)
print(f"直近      : {rc.iloc[-1]:.2f}")
print(f"平均      : {rc.mean():.2f}")
print(f"最小 / 最大: {rc.min():.2f} / {rc.max():.2f}")
print(f"0.4を下回った日数: {(rc < 0.4).sum()} / {len(rc)}")

実際に出力してみると、平均は0.6〜0.7あたりでも、局面によっては0.1近くまで下がるタイミングがちらほらありました。「相関係数0.8」という1つの数字だけを見て安心していた過去の自分に教えてあげたいです。

ウィンドウ幅でも数字は変わる

def window_sensitivity(ret: pd.DataFrame, windows=(20, 60, 120, 250)) -> pd.DataFrame:
    rows = []
    for w in windows:
        rc = rolling_corr(ret, window=w)
        rows.append({
            "window": w,
            "last": round(float(rc.iloc[-1]), 3),
            "mean": round(float(rc.mean()), 3),
            "min": round(float(rc.min()), 3),
            "std": round(float(rc.std()), 3),
        })
    return pd.DataFrame(rows).set_index("window")

print(window_sensitivity(ret))

20日窓はノイズだらけで毎日値が飛び、250日窓は変化に鈍すぎて崩れに気づけません。僕は60日を基準にして、20日窓を「早期警戒」として併用することにしました。数字が1つに定まらないということ自体が、この指標の性格です。

落とし穴③:見せかけの相関(擬似相関)

2つの時系列がどちらも右肩上がりのトレンドを持っているだけで、中身に何の関係がなくても相関係数は高く出ます。これは非定常性が原因の有名な落とし穴です。口で説明されても腑に落ちなかったので、無関係な乱数で実験してみました。

def spurious_demo(n: int = 500, trials: int = 200, seed: int = 0) -> dict:
    rng = np.random.default_rng(seed)
    price_corrs, ret_corrs = [], []
    for _ in range(trials):
        # 互いに完全に無関係な、ドリフト付きランダムウォーク2本
        ra = rng.normal(0.0004, 0.01, n)
        rb = rng.normal(0.0004, 0.01, n)
        pa = 1000 * np.cumprod(1 + ra)
        pb = 1000 * np.cumprod(1 + rb)
        price_corrs.append(np.corrcoef(pa, pb)[0, 1])
        ret_corrs.append(np.corrcoef(ra, rb)[0, 1])
    return {
        "price_corr_mean_abs": round(float(np.mean(np.abs(price_corrs))), 3),
        "price_over_0.7_pct": round(float(np.mean(np.abs(price_corrs) > 0.7) * 100), 1),
        "return_corr_mean_abs": round(float(np.mean(np.abs(ret_corrs))), 3),
        "return_over_0.7_pct": round(float(np.mean(np.abs(ret_corrs) > 0.7) * 100), 1),
    }

print(spurious_demo())

完全に無関係な系列なのに、価格で相関を取ると絶対値0.7超が何割も出ます。リターンで取ればほぼ0に張り付きます。「価格ではなくリターンで」という教科書的な注意が、なぜ必須なのかがこれで分かりました。

落とし穴④:相関は因果関係を意味しない

SOXと日本の半導体株の相関が高いのは「両方とも世界的な半導体需要という共通要因に反応している」からであって、SOXが日本株を動かしているわけではありません。片方だけに固有の悪材料が出れば、共通要因があっても簡単に逆行します。

そして相関が最も当てにならないのが、いちばん頼りたい局面です。下落日だけを切り出して測ってみます。

def regime_corr(ret: pd.DataFrame, lag: int = 1, threshold: float = -0.01) -> pd.DataFrame:
    df = pd.DataFrame({"TEL": ret["TEL"], "SOXX_lag": ret["SOXX"].shift(lag)}).dropna()
    rows = []
    for name, sub in [
        ("全期間", df),
        ("SOXX下落日", df[df["SOXX_lag"] <= threshold]),
        ("SOXX上昇日", df[df["SOXX_lag"] >= -threshold]),
    ]:
        rows.append({
            "regime": name,
            "n": len(sub),
            "corr": round(float(sub["TEL"].corr(sub["SOXX_lag"])), 3),
            "tel_mean_pct": round(float(sub["TEL"].mean() * 100), 3),
        })
    return pd.DataFrame(rows).set_index("regime")

print(regime_corr(ret))

「SOXXが下げた翌日」の相関は高く、「上げた翌日」の相関は低い、という非対称が出ることがあります。つまり下げは付いてくるが、上げは付いてこない。SOX高を根拠に買い上がるのが危ないのは、この非対称のせいでした。

ヘッジに使うなら相関ではなくβ

もう一つ、混同していたことがあります。相関係数は「方向が揃うか」しか教えてくれず、どれだけの量をぶつければ打ち消せるかは含んでいません。相関0.8でも、値動きの大きさが2倍違えば、同額でヘッジしても半分しか消えません。必要なのは回帰係数、いわゆるβです。

def hedge_ratio(ret: pd.DataFrame, lag: int = 1) -> dict:
    df = pd.DataFrame({"y": ret["TEL"], "x": ret["SOXX"].shift(lag)}).dropna()
    x, y = df["x"].values, df["y"].values
    beta = float(np.cov(x, y, ddof=1)[0, 1] / np.var(x, ddof=1))
    r = float(np.corrcoef(x, y)[0, 1])
    resid = y - beta * x
    return {
        "corr": round(r, 3),
        "beta": round(beta, 3),
        "r_squared": round(r ** 2, 3),
        "hedged_vol_ratio": round(float(resid.std(ddof=1) / y.std(ddof=1)), 3),
    }

print(hedge_ratio(ret))

hedged_vol_ratioは、βの比率でヘッジしたあとに残るブレの割合です。0.75なら、7割方は残るということ。相関0.8という数字から受ける印象より、ずっと消えません。r_squaredが0.64、つまり説明できるのは値動きの64%だけ、と考えると納得できます。

相関が崩れたら通知する

前回「監視銘柄すべてに定期計算させて、崩れたら通知」と書いたので、その部分を作りました。相関そのものより、相関の変化を監視対象にします。

WATCH = {
    "8035.T": "SOXX",   # 東京エレクトロン
    "6857.T": "SOXX",   # アドバンテスト
    "7203.T": "SPY",    # トヨタ
}

def corr_alert(pairs: dict[str, str], window: int = 60, lag: int = 1,
               drop: float = 0.25) -> pd.DataFrame:
    rows = []
    for a, b in pairs.items():
        px = pd.DataFrame({a: load_close(a), b: load_close(b)}).dropna()
        r = px.pct_change().dropna()
        rc = r[a].rolling(window).corr(r[b].shift(lag)).dropna()
        if len(rc) < window:
            continue
        now = float(rc.iloc[-1])
        base = float(rc.iloc[-window:-5].median())
        rows.append({
            "pair": f"{a} vs {b}",
            "now": round(now, 3),
            "baseline": round(base, 3),
            "delta": round(now - base, 3),
            "alert": "崩れ" if base - now >= drop else "",
        })
    return pd.DataFrame(rows).sort_values("delta")

print(corr_alert(WATCH))

基準値に中央値を使っているのは、直近5日を除いた分布の真ん中と比べたいからです。平均だと、崩れ始めた値自体に引きずられます。alertが立ったペアは、共通要因ではなく個別要因が動かし始めたサインとして見ています。

ポートフォリオ全体で相関がどう効いているかは複数銘柄の比較記事、崩れたときにどれだけ沈むかは最大ドローダウンの記事とセットで見ると判断しやすいです。

まとめ

相関係数は便利ですが、「時差を揃えたか」「いつの期間か」「価格かリターンか」「なぜ相関しているのか」を意識しないと簡単に足元をすくわれる指標だと再認識しました。今回のSOX高・日本半導体株安のねじれも、相関が崩れた瞬間を見せてもらったようなものです。

やることを整理すると4つです。lagを1にしてから測る。窓幅を変えて安定性を確認する。価格ではなくリターンで取る。上昇日と下落日を分けて測る。この4つを通したあとの数字なら、ポジションの根拠に使ってもいいと思えるようになりました。

ちなみに「0.8だから安心」と思っていた当時の数字を、lagを揃えて計算し直したら0.63でした。安心の根拠にしていた数字が、そもそも別物だったわけです。

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