ボラティリティ(価格変動の大きさ)は、リターンと並ぶ定量分析の二大指標の一つです。本記事は、日次リターンの標準偏差を年率換算した「ヒストリカル・ボラティリティ」で銘柄をスクリーニングし、結果をリターンとの散布図で比較するまでを通しで公開します。
過去の集計結果は将来の値動きを保証しません。
目次
- ボラティリティの定義
- 集計のゴール
- 必要なライブラリ
- コード(コピペで動く)
- 実行結果(イメージ)
- スクリーニング条件の組み立て
- チャートで「触って探る」
- 落とし穴
ボラティリティの定義
本記事ではヒストリカル・ボラティリティを次で計算します。
- : 日次の対数リターン
- 252: 年間の営業日数のおおよその値
- 標本標準偏差(
ddof=1)を使う
「直近 1 年(252 営業日)」を計算窓の基本にします。窓を変えると結果も変わるため、複数の窓で並行して見るのが定量分析の定石です。
集計のゴール
| 出力 | 内容 |
|---|---|
low_vol_top30.csv | 過去 252 営業日のボラティリティが低い銘柄 30 |
risk_return.png | 銘柄ごとの (年率リターン, 年率ボラ) 散布図 |
必要なライブラリ
pip install pandas numpy matplotlib検証バージョン: Python 3.12.5 / pandas 2.2.3 / numpy 2.0 / matplotlib 3.9
コード(コピペで動く)
"""low_volatility.py銘柄ごとに直近 252 営業日のヒストリカル・ボラティリティと年率リターンを計算し、低ボラティリティ・トップ 30 を抽出する。"""from __future__ import annotations
import numpy as npimport pandas as pdimport matplotlib.pyplot as plt
PRICES_PATH = "prices.csv" # Code, Date, CLISTED_PATH = "listed_info.csv" # Code, CoName, S33NmWINDOW = 252TOP_N = 30
def compute_metrics(df: pd.DataFrame) -> pd.DataFrame: """Code ごとに対数リターンの統計量を計算し、年率指標を返す。""" df = df.sort_values(["Code", "Date"]).copy() df["log_ret"] = np.log(df["C"] / df.groupby("Code")["C"].shift(1))
# 直近 WINDOW 営業日に絞る(銘柄ごと) df = df.dropna(subset=["log_ret"]) df = df.groupby("Code", group_keys=False).tail(WINDOW)
grouped = df.groupby("Code")["log_ret"] out = pd.DataFrame({ "n": grouped.size(), "ret_daily_mean": grouped.mean(), "ret_daily_std": grouped.std(ddof=1), }).reset_index()
out["ret_annual"] = out["ret_daily_mean"] * 252 out["vol_annual"] = out["ret_daily_std"] * np.sqrt(252) return out
def main() -> None: prices = pd.read_csv(PRICES_PATH, parse_dates=["Date"]) listed = pd.read_csv(LISTED_PATH)[["Code", "CoName", "S33Nm"]]
metrics = compute_metrics(prices) # 集計に必要な営業日が揃っている銘柄だけ採用(上場直後を弾く) metrics = metrics[metrics["n"] >= WINDOW * 0.9]
merged = metrics.merge(listed, on="Code") low_vol = ( merged.sort_values("vol_annual") .head(TOP_N) .reset_index(drop=True) ) low_vol["vol_annual_pct"] = (low_vol["vol_annual"] * 100).round(2) low_vol["ret_annual_pct"] = (low_vol["ret_annual"] * 100).round(2) print(low_vol[["Code", "CoName", "S33Nm", "ret_annual_pct", "vol_annual_pct"]]) low_vol.to_csv("low_vol_top30.csv", index=False)
# リスク・リターン散布図 fig, ax = plt.subplots(figsize=(8, 6)) ax.scatter(merged["vol_annual"] * 100, merged["ret_annual"] * 100, s=8, alpha=0.4, label="all") ax.scatter(low_vol["vol_annual"] * 100, low_vol["ret_annual"] * 100, s=30, color="crimson", label="low-vol top30") ax.set_xlabel("Volatility (annualized, %)") ax.set_ylabel("Return (annualized, %)") ax.set_title("Risk vs Return — last 252 trading days") ax.axhline(0, color="gray", linewidth=0.8) ax.grid(alpha=0.3) ax.legend() plt.tight_layout() plt.savefig("risk_return.png", dpi=120) plt.close(fig)
if __name__ == "__main__": main()
実行結果(イメージ)
Code CoName S33Nm ret_annual_pct vol_annual_pct0 XXXX サンプル食品A 食料品 4.32 12.451 XXXX サンプル食品B 食料品 5.10 13.222 XXXX サンプル医薬A 医薬品 3.85 13.503 XXXX サンプル鉄道A 陸運業 2.10 13.95...低ボラティリティ側には食料品・医薬品・陸運業など、業績が比較的安定する業種が並びがちです。
risk_return.png には、全銘柄のグレーの点と低ボラ・トップ 30 の赤い点が描かれます。低ボラ側に集まりつつ、リターンが正・負に分散している様子が見て取れるはずです。
スクリーニング条件の組み立て
低ボラだけで終わらせず、複数条件を組み合わせるのがスクリーニングの基本です。
condition = ( (merged["vol_annual"] < 0.20) # ボラ 20% 未満 & (merged["ret_annual"] > 0.0) # リターン プラス & (merged["n"] >= WINDOW) # 営業日が揃っている)filtered = merged[condition].sort_values("vol_annual")条件の数は 小さく始めて 1 つずつ追加 します。最初から多重条件を組むと、結果が 0 件になった原因を切り分けにくくなります。
チャートで「触って探る」
低ボラ・トップ 30 の銘柄は数が多いので、Plotly でホバー表示できる散布図にしておくと、後で振り返るときに名前と業種を確認しやすくなります。Plotly 版の散布図は#12-2「Plotly 応用 — リターン分布・相関行列・ドローダウンの可視化」 で扱います。
落とし穴
- 窓のサイズ依存: 60 日 / 252 日 / 504 日 で順位がかなり変わります
- 上場期間が短い銘柄: 観測数が足りない銘柄は標準偏差が信頼できません。
nでフィルタする - 取引停止・ストップ高安: 連日同値や寄らないストップが続くと、ボラが過小評価される
- 対数 vs 単純: 短期ならほぼ同じ。長期は対数の方が累積リターンと整合的
- 生存バイアス: 上場廃止銘柄を含まないデータで集計すると、低ボラ・高リターン側に楽観的なバイアスがかかる
まとめ
- ヒストリカル・ボラティリティは「日次リターン標準偏差 × √252」で年率化
- 低ボラ・トップ 30 は食料品・医薬品など安定業種が並びやすい
- リスク・リターン散布図でランキングと全体像を同時に把握する
- 窓サイズ・データ期間・対数 / 単純の選択を記録する
過去のボラティリティは将来の値動きを保証しません。