株価リターンは正規分布か|ファットテールを金額で測る

株価リターンは本当に正規分布か 基礎知識・戦略

※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。

「株価リターンは正規分布に従う」という仮定は、金融理論のあちこちで使われています。そして「実際はファットテール(裾が厚い)なので危険です」という指摘も、よく見かけます。

ただ、そこで話が終わっている記事がほとんどです。裾が厚いと、具体的にいくら損するのか。この記事では、日経平均の実データで「理論では何十年に1度のはずの日」が実際に何回起きたかを数え、正規分布を前提としたリスク指標がどれだけ損失を過小評価するかを金額で出します。

そのうえで、ファットテールの主な原因がボラティリティの変動にあることを確かめます。ここが分かると、対処法も見えてきます。

📘 外部参考scipy.stats.norm(公式)正規分布(Wikipedia 日本語)

正規分布の前提を確認する

範囲理論上の割合営業日換算での頻度
平均 ± 1σ68.27%3日に1回は外れる
平均 ± 2σ95.45%約22日に1回外れる
平均 ± 3σ99.73%約1.5年に1回
平均 ± 4σ99.9937%約63年に1回
平均 ± 5σ99.999943%約1万4千年に1回

5σは1万4千年に1回。これが正規分布の主張です。実際のデータで数えてみます。

何σの日が実際に何回起きたか

import numpy as np
import pandas as pd
import yfinance as yf
from scipy import stats

def log_returns(ticker: str, period: str = "20y") -> 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)
    return np.log(df["Close"]).diff().dropna()

def sigma_table(r: pd.Series) -> pd.DataFrame:
    mu, sd = r.mean(), r.std(ddof=1)
    n = len(r)
    rows = []
    for k in (2, 3, 4, 5, 6):
        observed = int((np.abs(r - mu) > k * sd).sum())
        prob = 2 * stats.norm.sf(k)              # 両側の理論確率
        expected = prob * n
        rows.append({
            "sigma": f"{k}σ超",
            "observed": observed,
            "expected": round(expected, 3),
            "ratio": round(observed / expected, 1) if expected > 0 else np.inf,
            "理論上の間隔": f"{1 / (prob * 252):,.0f}年に1回",
        })
    return pd.DataFrame(rows).set_index("sigma")

r = log_returns("^N225")
print(f"サンプル数: {len(r)}日  σ={r.std()*100:.2f}%")
print(sigma_table(r))

日経平均の20年分で実行すると、こうなります(期間によって多少変わります)。

閾値実際の回数正規分布の期待回数倍率
3σ超50回前後13回程度約4倍
4σ超15回前後0.3回程度約50倍
5σ超5回前後0.001回程度数千倍
6σ超数回ほぼゼロ

1万4千年に1回のはずの5σが、20年で5回。2024年8月5日の日経平均は1日で12%以上下落しましたが、これは平常時のσで測ると7σを超えます。正規分布では宇宙の年齢より長い間隔でしか起きない事象です。

つまり、正規分布は「起きない」と言っているのではなく、モデルが間違っているということです。

分布の形を数値化する

def shape_stats(r: pd.Series) -> dict:
    jb_stat, jb_p = stats.jarque_bera(r)
    # 実データにt分布を当てはめて自由度を推定
    dfree, loc, scale = stats.t.fit(r.to_numpy())
    return {
        "mean_pct": round(r.mean() * 100, 4),
        "std_pct": round(r.std(ddof=1) * 100, 3),
        "skew": round(float(stats.skew(r)), 3),
        "excess_kurtosis": round(float(stats.kurtosis(r)), 2),  # 正規なら0
        "jarque_bera_p": f"{jb_p:.2e}",
        "t_df": round(float(dfree), 2),
    }

print(shape_stats(r))
  • 歪度(skew)が負なら、下落側の裾が長い。株価指数はたいてい -0.3〜-1.0 になります。上げは緩やかに、下げは急に。
  • 超過尖度(excess kurtosis)は正規分布で0。日経平均では5〜10になります。
  • t分布の自由度が3〜5に推定されます。自由度が小さいほど裾が厚く、自由度4以下では尖度が理論上は無限大です。

ジャルク・ベラ検定のp値はほぼゼロになりますが、これはあまり意味がありません。サンプルが数千あれば、わずかなズレでも検定は必ず棄却します。「正規分布ではない」ことを検定で確かめるより、「どれだけズレるか」を測るほうが実用的です。

ファットテールをお金に換算する:VaRとCVaR

ここが本題です。正規分布を仮定したリスク指標が、実際にどれだけ損失を過小評価するかを見ます。

def risk_compare(r: pd.Series, capital: float = 10_000_000,
                 levels=(0.05, 0.01, 0.001)) -> pd.DataFrame:
    mu, sd = r.mean(), r.std(ddof=1)
    rows = []
    for a in levels:
        var_norm = mu + sd * stats.norm.ppf(a)          # 正規VaR
        var_hist = float(np.quantile(r, a))             # ヒストリカルVaR
        tail = r[r <= var_hist]
        cvar_hist = float(tail.mean())                  # 期待ショートフォール
        # 正規分布のCVaR(解析解)
        cvar_norm = mu - sd * stats.norm.pdf(stats.norm.ppf(a)) / a
        rows.append({
            "信頼水準": f"{(1 - a) * 100:.1f}%",
            "正規VaR": f"{var_norm * capital:,.0f}円",
            "実測VaR": f"{var_hist * capital:,.0f}円",
            "正規CVaR": f"{cvar_norm * capital:,.0f}円",
            "実測CVaR": f"{cvar_hist * capital:,.0f}円",
            "過小評価率": f"{cvar_hist / cvar_norm:.2f}倍",
        })
    return pd.DataFrame(rows).set_index("信頼水準")

print(risk_compare(r))

1000万円のポジションで見ると、99.9%水準あたりから差が顕著になります。正規分布では「40万円の損失」と出るところが、実測では70万円を超えるといった具合です。

ここでVaRではなくCVaR(期待ショートフォール)を見ているのには理由があります。VaRは「99%の日はこれ以下の損失」としか言いません。残り1%の日にいくら損するかは何も答えていないのです。ファットテールが効くのはまさにその1%なので、VaRだけを見るのは危険です。CVaRは「その1%の日の平均損失」なので、裾の厚さが数字に反映されます。

ファットテールの正体はボラティリティの変動

ここが実務的にいちばん重要なところです。リターン分布が厚い裾を持つ主な原因は、ボラティリティが時期によって大きく変わることにあります。

平常時のσが0.8%、危機時のσが4%だとします。この2つを混ぜて1つのヒストグラムにすれば、当然、裾が厚くなります。「1つの正規分布に従っていない」だけで、「各時点では正規分布に近い」可能性があるわけです。

確かめるには、各日のリターンをその時点のボラティリティで割って標準化します。

def standardize_by_vol(r: pd.Series, lam: float = 0.94) -> pd.Series:
    """EWMAボラティリティで標準化する(前日までの情報のみ使用)。"""
    var = r.ewm(alpha=1 - lam).var(bias=True).shift(1)   # shiftで未来参照を防ぐ
    z = (r / np.sqrt(var)).dropna()
    return z[np.isfinite(z)]

z = standardize_by_vol(r)
print("生リターンの超過尖度      :", round(float(stats.kurtosis(r)), 2))
print("ボラ標準化後の超過尖度    :", round(float(stats.kurtosis(z)), 2))
print("生リターンの4σ超         :", int((r.abs() > 4 * r.std()).sum()))
print("標準化後の4σ超           :", int((z.abs() > 4).sum()))

.shift(1)を入れているのが重要です。当日のリターンを含んだボラティリティで当日を標準化すると、大暴落の日が自動的に小さく見えてしまいます。これは未来参照です。

実行すると、超過尖度が7から2程度まで下がります。完全に正規分布にはなりませんが、裾の厚さの大部分がボラティリティ変動で説明できることが分かります。

この事実には実践的な意味があります。ポジションサイズを固定額ではなく、直近のボラティリティに反比例させれば、損益の分布はかなり扱いやすくなるということです。

def vol_target_position(r: pd.Series, target_annual_vol: float = 0.15,
                        capital: float = 10_000_000, cap: float = 2.0) -> pd.Series:
    """年率ボラを一定に保つポジション比率。"""
    realized = r.ewm(span=20).std().shift(1) * np.sqrt(252)
    w = (target_annual_vol / realized).clip(upper=cap)
    return (w * capital).rename("position_yen")

pos = vol_target_position(r)
pnl_fixed = r * 10_000_000
pnl_vt = r * pos.shift(0).reindex(r.index)

for name, p in [("固定額", pnl_fixed), ("ボラ調整", pnl_vt.dropna())]:
    print(f"{name:6s} 標準偏差={p.std():>10,.0f}円  "
          f"最悪日={p.min():>12,.0f}円  超過尖度={stats.kurtosis(p.dropna()):.2f}")

ボラティリティ調整をかけると、最悪日の損失が目に見えて小さくなります。危機に入る前にポジションを落としているからです。裾を消すことはできませんが、裾に晒す金額を減らすことはできます。

QQプロットで視覚的に確認する

import matplotlib
import matplotlib.pyplot as plt

matplotlib.rcParams["font.family"] = ["Meiryo", "Yu Gothic", "IPAexGothic"]

fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))

mu, sd = r.mean(), r.std(ddof=1)
x = np.linspace(r.min(), r.max(), 400)
axes[0].hist(r, bins=120, density=True, alpha=0.6,
             color="steelblue", label="実際の分布")
axes[0].plot(x, stats.norm.pdf(x, mu, sd), "r-", lw=2, label="正規分布")
axes[0].set_yscale("log")           # 対数軸にすると裾の差が見える
axes[0].set_title("日次リターン分布(縦軸=対数)")
axes[0].legend()

stats.probplot(r, dist="norm", plot=axes[1])
axes[1].set_title("QQプロット(正規分布)")

stats.probplot(z, dist="norm", plot=axes[2])
axes[2].set_title("QQプロット(ボラ標準化後)")

plt.tight_layout()
plt.savefig("return_distribution.png", dpi=150)

ヒストグラムの縦軸を対数にするのがコツです。通常の軸だと中央のピークばかり目立って、肝心の裾がまったく見えません。対数軸にすると、実データの点が正規分布の曲線から大きく外れて広がっているのが一目で分かります。

QQプロットでは、正規分布なら点が直線に乗ります。実データは両端がS字に外れます。3枚目のボラ標準化後のプロットでは、その外れ方がかなり緩和されているはずです。

まとめ

  • 正規分布で「1万4千年に1回」の5σが、日経平均では20年に数回起きている
  • 正規分布を前提としたVaRは、極端な損失を数割から数倍過小評価する
  • VaRではなくCVaR(期待ショートフォール)を見る。VaRは裾の中身を語らない
  • ファットテールの主因はボラティリティの変動。標準化すると尖度は大きく下がる
  • だからボラティリティに反比例させたポジションサイズが有効に働く
  • 可視化するときはヒストグラムを対数軸にする。裾は線形軸では見えない

「ファットテールに気をつけよう」で終わらせず、自分の運用額でCVaRを計算してみてください。その金額を受け入れられるかどうかが、レバレッジを決める根拠になります。

📘 外部参考pandas User Guide(公式)

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