Pythonで標準偏差を使って投資リスクを数値化する方法

投資リスクを数字で捉える 基礎知識・戦略

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

「この銘柄はリスクが高い」という言い方をよくしますが、ではどのくらい高いのかを数字で答えられるでしょうか。投資の世界でリスクを測る最も基本的な物差しが標準偏差です。これを計算できるようになると、「なんとなく怖い」が「年率28%の変動幅」という具体的な情報に変わります。

この記事では、Pythonで株価データから標準偏差を計算し、年率ボラティリティに換算して複数銘柄を横並びで比較するところまでを解説します。あわせて、標準偏差という指標が何を捉えていて何を見落とすのか——ここを理解しないまま使うと危険な理由も、実データで確認していきます。

結論を先に書いておくと、標準偏差は銘柄同士のリスクを比較する物差しとしては極めて優秀ですが、暴落の大きさを見積もる道具としては不十分です。この使い分けが本記事の核心になります。

📘 外部参考Standard Deviation(Investopedia)標準偏差(Wikipedia)

リスクを「ばらつき」として定義する

投資におけるリスクとは「損をする可能性」だと思われがちですが、統計的には「結果がどれだけばらつくか」を指します。上に大きく振れることも、下に大きく振れることも、どちらもリスクとして数えます。

この定義に違和感を持つ方も多いはずです。「上がる分にはうれしいのに、なぜリスク扱いなのか」と。ただ、大きく上がる銘柄は大きく下がる銘柄でもある——これは実際のデータを見ればほぼ例外なく成り立ちます。だからこそ、ばらつきの大きさをそのままリスクの代理指標として使うわけです。

もう1つ、この定義には実務上の利点があります。ばらつきは事前に測れるという点です。「この銘柄は将来いくら下がるか」は誰にも分かりませんが、「この銘柄は過去1年、平均してどれくらい揺れてきたか」なら計算できます。将来の損失額を当てにいくのではなく、過去の揺れ幅から今後の揺れ幅を推定する——これが標準偏差を使ったリスク管理の基本的な発想です。

もちろん、過去の揺れ方が将来も続く保証はありません。ただ、ボラティリティには「大きい時期は大きいまま、小さい時期は小さいまま続きやすい」というクラスタリング性が知られており、短期の推定であれば実用に耐えます。リターンの予測は難しいがリスクの予測はある程度できる——この非対称性が、リスク管理という分野が成立している理由でもあります。

なぜリターンの標準偏差を取るのか

ここでよくある間違いが、株価そのものの標準偏差を計算してしまうことです。株価3,000円の銘柄と株価300円の銘柄では、価格の標準偏差は当然桁が違います。しかしそれは値動きの激しさではなく、単に株価の水準を反映しているだけです。

計算対象意味銘柄間の比較
株価の標準偏差価格の絶対的なばらつき(円)できない(株価水準に依存)
リターンの標準偏差変動率のばらつき(%)できる

比較したいなら、必ず日次リターン(前日比の変化率)に直してから標準偏差を取ります。これで「株価3,000円の銘柄は1日平均1.2%動く」「株価300円の銘柄は2.8%動く」といった、水準に左右されない比較ができるようになります。

Pythonで標準偏差を計算する

必要なライブラリを準備します。

pip install yfinance pandas numpy matplotlib

トヨタ自動車(7203.T)の株価から、日次リターンの標準偏差を求めてみます。

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

df = yf.download("7203.T", period="1y", auto_adjust=True, progress=False)

# 日次リターン(変化率)を計算する
df["ret"] = df["Close"].pct_change()

daily_std = df["ret"].std()
print(f"日次リターンの平均      : {df['ret'].mean() * 100:>7.4f} %")
print(f"日次リターンの標準偏差  : {daily_std * 100:>7.4f} %")
print(f"データ日数              : {df['ret'].count():>7d} 日")

ここで auto_adjust=True を指定している点が重要です。株式分割や配当の影響を調整した株価を使わないと、権利落ち日に見せかけの大暴落が記録され、標準偏差が不当に大きくなります。

単純リターンと対数リターン

リターンの計算には2通りあります。使い分けを知っておくと混乱しません。

# 単純リターン: (今日 - 昨日) / 昨日
simple = df["Close"].pct_change()

# 対数リターン: log(今日 / 昨日)
log_ret = np.log(df["Close"] / df["Close"].shift(1))

print(f"単純リターンの標準偏差: {simple.std() * 100:.4f} %")
print(f"対数リターンの標準偏差: {log_ret.std() * 100:.4f} %")

日次のように変化率が小さい場合、両者はほぼ同じ値になります。違いが出るのは大きな変動があったときで、対数リターンは足し算ができるという性質から、期間をまたぐ集計では対数リターンが好まれます。日々のリスク計測が目的なら、単純リターンで十分です。

年率ボラティリティへの換算

日次の標準偏差が1.5%と言われても、大きいのか小さいのか判断しづらいはずです。金融の世界では年率に換算した数値(年率ボラティリティ)で語るのが慣例なので、これに直します。

換算式はシンプルで、日次標準偏差 × √252 です。252は1年間のおおよその営業日数です。

TRADING_DAYS = 252

annual_vol = daily_std * np.sqrt(TRADING_DAYS)
print(f"日次ボラティリティ: {daily_std * 100:>6.2f} %")
print(f"年率ボラティリティ: {annual_vol * 100:>6.2f} %")

# 期間別の換算も同じ考え方
for label, days in [("週次", 5), ("月次", 21), ("四半期", 63), ("年率", 252)]:
    print(f"  {label:>4s}: {daily_std * np.sqrt(days) * 100:>6.2f} %")

なぜ日数ではなく「平方根」を掛けるのか

直感的には252倍したくなりますが、それでは過大評価になります。理由は、上がった日と下がった日が打ち消し合うからです。

もし毎日必ず同じ方向に動くのであれば、変動は日数に比例して積み上がります。しかし実際の株価は上下にランダムに揺れるため、ばらつきの累積は時間の平方根に比例して増えます。これは酔歩(ランダムウォーク)の基本的な性質で、ブラック・ショールズ式で√Tが登場するのも同じ理屈です。

期間営業日数倍率(√日数)日次1.5%なら
1日11.001.50%
1週間52.243.35%
1か月214.586.87%
1年25215.8723.81%

「日次1.5%」と「年率23.8%」は同じ事実の別表現です。年率に直すと、市場全体との比較がしやすくなります。日経平均の年率ボラティリティは平常時でおおむね18〜22%程度なので、それより高ければ市場平均より値動きが荒い銘柄だと判断できます。

複数銘柄のリスクを横並びで比較する

ここまで来れば、銘柄ごとのリスクを一覧にできます。製造業を中心に、性格の違う銘柄を並べてみます。

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

TICKERS = {
    "7203.T": "トヨタ自動車",
    "6758.T": "ソニーグループ",
    "8306.T": "三菱UFJ",
    "6501.T": "日立製作所",
    "9432.T": "NTT",
    "^N225":  "日経平均",
}
TRADING_DAYS = 252

rows = []
for code, name in TICKERS.items():
    px = yf.download(code, period="1y", auto_adjust=True, progress=False)["Close"]
    ret = px.pct_change().dropna()
    ann_vol = float(ret.std()) * np.sqrt(TRADING_DAYS)
    ann_ret = float(ret.mean()) * TRADING_DAYS
    rows.append({
        "銘柄": name,
        "年率リターン": round(ann_ret * 100, 1),
        "年率ボラティリティ": round(ann_vol * 100, 1),
        "リターン/リスク": round(ann_ret / ann_vol, 2) if ann_vol else np.nan,
        "最大下落日": round(float(ret.min()) * 100, 1),
    })

df = pd.DataFrame(rows).sort_values("年率ボラティリティ")
print(df.to_string(index=False))

この表が作れると、銘柄選びの見方が変わります。年率リターンが同じでもボラティリティが小さいほうが優秀という判断ができるようになるからです。「リターン/リスク」の列がその効率を表しており、これを厳密にしたものがシャープレシオです。詳しくはシャープレシオを使って投資戦略を評価する方法で解説しています。

ボラティリティは時期によって変わる

1年分をまとめて計算した数値は「平均的な姿」でしかありません。実際のボラティリティは時期によって大きく変動します。ローリング計算で推移を見ると、その動きがはっきり分かります。

import matplotlib.pyplot as plt

px = yf.download("7203.T", period="3y", auto_adjust=True, progress=False)["Close"]
ret = px.pct_change().dropna()

# 直近20日のボラティリティを年率換算しながら転がす
rolling_vol = ret.rolling(20).std() * np.sqrt(252) * 100

fig, ax = plt.subplots(figsize=(11, 4.5))
ax.plot(rolling_vol.index, rolling_vol, linewidth=1.2)
ax.axhline(rolling_vol.mean(), linestyle="--", linewidth=1,
           label=f"平均 {rolling_vol.mean():.1f}%")
ax.set_ylabel("年率ボラティリティ (%)")
ax.set_title("20日ローリング・ボラティリティの推移")
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("rolling_vol.png", dpi=120)

print(f"最小: {rolling_vol.min():.1f}%  最大: {rolling_vol.max():.1f}%")
print(f"最大は最小の {rolling_vol.max() / rolling_vol.min():.1f} 倍")

実行すると、静かな時期と荒れた時期でボラティリティが3〜5倍も違うことが分かります。つまり「この銘柄の年率ボラティリティは24%です」という表現は、平均としては正しくても、いま現在のリスクを表しているとは限りません。リスク管理に使うなら、直近20日程度のローリング値を見るほうが実態に即しています。

標準偏差から確率を読む——ただし限界がある

リターンが正規分布に従うと仮定すると、標準偏差から「どのくらいの確率でどこまで動くか」を見積もれます。教科書に必ず出てくる、いわゆる68-95-99.7ルールです。

範囲理論上の確率年率ボラ24%の銘柄なら
±1標準偏差約68%年間リターンが −24%〜+24% に収まる
±2標準偏差約95%−48%〜+48% に収まる
±3標準偏差約99.7%−72%〜+72% に収まる

ここまでは教科書どおりです。問題は、この理論値が現実と合っていない点にあります。

# 理論と実際の発生回数を比べる
px = yf.download("^N225", period="10y", auto_adjust=True, progress=False)["Close"]
ret = px.pct_change().dropna()
z = (ret - ret.mean()) / ret.std()   # 標準化

n = len(z)
for k in (2, 3, 4, 5):
    actual = int((z.abs() > k).sum())
    # 正規分布なら起こるはずの回数
    from scipy.stats import norm
    expected = 2 * norm.sf(k) * n
    print(f"±{k}σ超え  実際 {actual:>4d} 回 / 理論 {expected:>7.2f} 回"
          f"  → {actual / expected if expected else 0:>6.1f} 倍")

実行すると、4σや5σを超える日が理論値の数十倍から数百倍の頻度で起きていることが確認できます。正規分布であれば5σの日は数千年に一度のはずですが、現実の相場では数年に一度は経験します。

これがファットテール(裾の厚さ)と呼ばれる現象です。標準偏差は「平常時のばらつき」をよく表しますが、暴落の大きさを見積もる道具としては役に立ちません。この点は正規分布と株価リターンの現実を確認する方法で詳しく検証しているので、リスク管理を真剣に考えるなら必ず押さえておいてください。

下落だけを測る「下方偏差」

標準偏差のもう1つの弱点は、上昇と下落を同じ重さで扱うことです。投資家にとって困るのは下落だけなので、上昇の大きさまでリスクに数えるのは不自然に感じられます。

そこで使われるのが下方偏差(ダウンサイド・デビエーション)です。マイナスのリターンだけを対象に標準偏差を計算します。

def downside_deviation(returns, target=0.0, periods=252):
    """目標を下回った分だけでばらつきを測る"""
    shortfall = np.minimum(returns - target, 0.0)
    return np.sqrt((shortfall ** 2).mean()) * np.sqrt(periods)


for code, name in [("7203.T", "トヨタ"), ("6758.T", "ソニー"), ("^N225", "日経平均")]:
    r = yf.download(code, period="3y", auto_adjust=True,
                    progress=False)["Close"].pct_change().dropna()
    std = float(r.std()) * np.sqrt(252)
    dd = downside_deviation(r)
    print(f"{name:<8s} 標準偏差 {std * 100:>5.1f}%  "
          f"下方偏差 {float(dd) * 100:>5.1f}%  比 {float(dd) / std:>4.2f}")

比が1.0に近ければ上下対称、1.0を大きく超えていれば下げが荒い銘柄ということになります。同じ年率ボラティリティ20%でも、じわじわ上げて急に落ちる銘柄と、まんべんなく揺れる銘柄では、投資家の体感がまったく違います。下方偏差はその差を捉えてくれます。

指標の選び方をまとめると、次のようになります。

  • 標準偏差: 銘柄同士を比べたいとき。最も広く使われ、他人と会話が通じる
  • 下方偏差: 下げの荒さを知りたいとき。ソルティノレシオの分母にも使われる
  • 最大ドローダウン: 実際にどこまで含み損を抱えたかを知りたいとき

実際の損失体験に近い指標としては、最大ドローダウンもあわせて確認することをおすすめします。計算方法は最大ドローダウンを自動計算するコードにまとめています。

ボラティリティの測り方は1つではない

ここまで「終値の日次リターンの標準偏差」を使ってきましたが、これは数ある測り方の1つにすぎません。それぞれ長所と短所があるので、目的に応じて選びます。

手法使うデータ特徴向いている用途
単純標準偏差終値最も基本。全期間を等しく扱う銘柄の性格を知る
EWMA終値直近ほど重く見る。変化に速く追随現在のリスク把握
パーキンソン高値・安値日中の値動きを捉える。効率が良い日中の荒さを見る
ATR高値・安値・終値価格単位(円)で出る損切り幅の設定

EWMA: 直近の変化に速く反応させる

単純な標準偏差は、1年前の値動きも昨日の値動きも同じ重さで扱います。しかし相場の環境が変わったとき、これでは反応が鈍くなります。指数加重移動平均(EWMA)を使うと、直近のデータほど重く評価できます。

import yfinance as yf
import numpy as np

px = yf.download("^N225", period="2y", auto_adjust=True, progress=False)["Close"]
ret = px.pct_change().dropna()

# 単純(60日窓) vs EWMA(減衰係数0.94はリスク管理での定番値)
simple_vol = ret.rolling(60).std() * np.sqrt(252) * 100
ewma_vol = ret.ewm(alpha=1 - 0.94).std() * np.sqrt(252) * 100

compare = pd.DataFrame({"単純60日": simple_vol, "EWMA": ewma_vol}).dropna()
print(compare.tail(10).round(1).to_string())
print(f"\n最終日の差: {abs(compare.iloc[-1, 0] - compare.iloc[-1, 1]):.1f} ポイント")

相場が急変した直後を見ると、EWMAのほうが早くリスク上昇を検知していることが分かります。逆に落ち着いた局面では、EWMAのほうが早く低下します。リスク管理の実務でEWMAが好まれるのは、この追随の速さが理由です。

パーキンソン: 高値と安値を使う

終値だけを見ていると、日中に大きく振れて元の水準に戻った日を「変動なし」と判定してしまいます。実際にはヒヤヒヤする1日だったのに、です。高値と安値を使うパーキンソン推定量なら、この日中の荒さを拾えます。

def parkinson_volatility(df, periods=252):
    """高値・安値から日中の変動幅を使ってボラティリティを推定する"""
    hl = np.log(df["High"] / df["Low"])
    var = (hl ** 2) / (4 * np.log(2))
    return np.sqrt(var.mean()) * np.sqrt(periods)


df = yf.download("7203.T", period="1y", auto_adjust=True, progress=False)
close_vol = float(df["Close"].pct_change().std()) * np.sqrt(252)
park_vol = float(parkinson_volatility(df))

print(f"終値ベース      : {close_vol * 100:>5.1f} %")
print(f"パーキンソン    : {park_vol * 100:>5.1f} %")
print(f"比              : {park_vol / close_vol:>5.2f}")

比が1を大きく超える場合、日中は激しく動くが終値では落ち着くタイプの銘柄です。デイトレードでは体感リスクが高く、スイング以上の期間で持つなら見た目ほど荒くない、という読み方ができます。同じ「年率ボラティリティ」という言葉でも、測り方によって伝えている情報が違うわけです。

計算で間違えやすいポイント

母集団標準偏差と標本標準偏差

pandasの .std() は既定で標本標準偏差(n−1で割る)を返しますが、NumPyの np.std()母集団標準偏差(nで割る)を返します。同じデータでも結果が微妙に変わるため、混在させないよう注意してください。

r = df["ret"].dropna()
print(f"pandas .std()        : {r.std():.8f}   (ddof=1)")
print(f"numpy  np.std()      : {np.std(r):.8f}   (ddof=0)")
print(f"numpy  ddof=1指定    : {np.std(r, ddof=1):.8f}")

データが数百件あれば差はごくわずかですが、短い期間で計算するときは無視できない差になります。どちらを使うかを決めて統一しておきましょう。

欠損値を放置する

pct_change() の1行目は必ずNaNになります。また、休場日やデータ欠損があると計算がずれます。.dropna() を必ず挟むこと、そして複数銘柄を比較するときは日付を揃えることが重要です。

# 複数銘柄は結合してから欠損行を落とす(日付を揃える)
prices = yf.download(["7203.T", "6758.T", "6501.T"], period="1y",
                     auto_adjust=True, progress=False)["Close"]
rets = prices.pct_change().dropna(how="any")   # どれか欠けている日は除外
print(f"揃えた後の日数: {len(rets)} 日")
print((rets.std() * np.sqrt(252) * 100).round(1))

短すぎる期間で判断する

10日程度のデータで標準偏差を出しても、値はほとんど当てになりません。最低でも60営業日、できれば1年分は確保してください。逆に長すぎる期間を使うと、現在の相場環境と乖離した平均値になります。目的に応じて、直近の姿を見るなら20〜60日、銘柄の性格を見るなら1〜3年、と使い分けるのが実務的です。

よくある疑問

営業日は252日と250日、どちらが正しいのか

日本市場の年間営業日数はおおむね245〜250日、米国市場は252日前後です。厳密を期すなら市場ごとに変えるべきですが、結果への影響はごくわずかです。

daily = 0.015
for d in (245, 250, 252, 260):
    print(f"√{d} 換算: {daily * np.sqrt(d) * 100:.2f} %")

245と260でも差は0.5ポイント程度です。重要なのは正確さより一貫性で、比較する数値の間で同じ日数を使っていれば問題ありません。本記事では慣例に従って252を採用しています。

ボラティリティが低い銘柄を選べば安全なのか

そうとは限りません。次の3点に注意が必要です。

  • 流動性が低いだけのことがある。売買が少なく株価が動いていないだけで、いざ売ろうとすると値がつかない
  • 低ボラの期間は続かない。静かな時期のあとに急変が来るのは、相場ではよくある展開
  • リターンも低い傾向がある。リスクを下げれば期待できる収益も下がるのが原則

ボラティリティは「安全な銘柄を探す指標」ではなく、「どれだけの揺れに耐える覚悟が必要かを事前に知る指標」だと考えるのが正確です。

分単位や時間足でも同じように計算できるか

できます。年率換算の際に掛ける数を変えるだけです。ただし短い足になるほど、値幅の刻みやスプレッドの影響で実態より大きめの数値が出やすい点には注意してください。

  • 1時間足(株式・1日5時間): √(5 × 252) = √1260 を掛ける
  • 5分足(1日60本): √(60 × 252) を掛ける
  • 週次データ: √52 を掛ける
def annualize(daily_std, bars_per_day=1, trading_days=252):
    """任意の足のボラティリティを年率に換算する"""
    return daily_std * np.sqrt(bars_per_day * trading_days)


# 同じ0.3%でも、足によって年率換算後の意味はまったく違う
for label, bars in [("日足", 1), ("1時間足", 5), ("5分足", 60)]:
    print(f"{label:>7s}の標準偏差0.3% → 年率 {annualize(0.003, bars) * 100:>6.1f} %")

日経平均そのもののボラティリティはどこで確認できるか

過去の実績を自分で計算する方法は本記事のとおりですが、「市場が今後をどう見ているか」を知りたい場合は日経平均ボラティリティー・インデックス(日経平均VI)を参照します。これはオプション価格から逆算された将来予想であり、過去データから計算する実現ボラティリティとは性質が異なります。両者を並べて見ると、市場の警戒度が実態より高いか低いかが判断できます。

まとめ

標準偏差を使った投資リスクの数値化について、計算方法から限界まで見てきました。要点を整理します。

  • 比較したいなら株価ではなくリターンの標準偏差を取る。株価水準に左右されなくなる
  • 年率換算は日次標準偏差 × √252。日数ではなく平方根を掛けるのは、上下が打ち消し合うため
  • 年率ボラティリティに直すと、日経平均(平常時18〜22%程度)と比較できる
  • ボラティリティは時期で3〜5倍変わる。現在のリスクを見るならローリング計算を使う
  • 68-95-99.7ルールは平常時の目安。±4σ以上の事象は理論値より桁違いに多く発生する
  • 下落だけを測りたいなら下方偏差を併用する
  • 測り方は1つではない。追随の速さならEWMA、日中の荒さならパーキンソン
  • ddofの違い、欠損値、期間の短さ——この3つが計算ミスの定番

標準偏差は「銘柄同士を比べる物差し」としては信頼できますが、「最悪どこまで落ちるか」を教えてはくれません。この二面性を理解したうえで使えば、感覚頼りだった銘柄選びが一段階しっかりしたものになります。

統計指標を投資に持ち込むときに大事なのは、その指標が何を見ていて、何を見ていないかを言えることです。標準偏差の場合は「平常時のばらつきは見ているが、暴落の深さは見ていない」。この一文が言えるだけで、数字への向き合い方が変わります。

まずは自分の保有銘柄で年率ボラティリティを計算し、日経平均と比べてみるところから始めてみてください。「思ったより荒い銘柄を持っていた」と気づくことは珍しくありません。そこが、リスクを感覚ではなく数字で管理する出発点になります。

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