ある混雑度ファクターの紹介(Comomentum)
本記事はマケデコアドベントカレンダー7日目の記事です。
はじめに
「業績は絶好調、ニュースもポジティブ。なのに株価が突然暴落した」
皆さんは、こんな理不尽な現象に遭遇したことはありませんか?
多くの個人投資家がパニックになる一方で、ヘッジファンドのクオンツ(計量分析官)たちは冷ややかにこう呟くそうです。
「ああ、出口が詰まった(Crowded)んだな」 と。
これはCrowdingと呼ばれる現象の一つです。どれほど素晴らしい銘柄でも、あまりに多くの投資家が同じポジションに殺到していれば、些細なきっかけで売りが売りを呼ぶ「取り付け騒ぎ」が発生します。
本記事では、この見えざるリスクをPythonで実装し、可視化した結果を解説します。
単なる相関係数の計算ではなく、Lou & Polk (2013) の論文に基づいたComomentum(コモメンタム)という、より実践的でアカデミックな手法を紹介します。
1. 理論編:なぜ「相関」が「混雑」になるのか?
映画館の火事
混雑度リスクは、よく「出口の狭い映画館」に例えられます。
映画(銘柄)が人気であるほど観客(投資家)は増えますが、何らかのショック(火事)が起きた時、全員が一斉に出口へ殺到するため、将棋倒し(流動性の枯渇)が起きます。
Comomentum(コモメンタム)の解説
この「混雑」をどうやって数値化するか?
ここで登場するのが Lou & Polk (2013) が提唱した Comomentum です。彼らの主張はシンプルです。
「多くのファンドが同じ戦略(モメンタム等)に群がると、その銘柄群はファンダメンタルズを無視して『過剰に連動』し始める」
通常、A社とB社は別々の動きをするはずです。しかし、全員が「AもBも買いだ!」と同じアルゴリズムで動くと、AとBは一心同体のように動き始めます。
ちなみに分かる人向けの補足ですが、リーマンショックなどの大きなショックが起きるとどの銘柄も大きく暴落するため、どの銘柄も似たような相関となり、ポートフォリオ理論による分散効果が期待できなくなってしまいます。そのため、ロングショート戦略でもない限りは、ポートフォリオは暴落してしまうと理解されるそうです。
余談ですが、空売りやオプションなどの取引は法学的(?)には投機的な取引と解釈されることがあるそうですが、ポートフォリオ理論を学ぶとヘッジを入れた戦略とロングオンリーの戦略、どちらが投機的な取引かよくわからなくなることがあります。おそらくクオンツが広く知られていない時代に制度が決まったのではないかと思いますが、こういうのは大陸法では取扱が難しいですね。
数式による定義
「過剰な連動」を測るため、Comomentumは以下のステップで計算されます。
-
市場要因の除去(直交化)
市場全体が暴落している時に相関が上がるのは当たり前です。そこで、CAPMモデル等を用いて市場ベータを除去した**「残差(Residuals)」**を抽出します。
-
残差相関の計算
モメンタム上位銘柄(Winner)同士の、残差のペアワイズ相関を計算します。
この値が高ければ高いほど、その市場は「異常に混雑している」と判断できます。
2. 実装編:PythonによるComomentumの計算
簡単な実装をいかに置いておきます。Geminiが一瞬でやってくれました。 Geminiがやってくれるので省略。(間違っていると怖いし) (ガッチリテスト書いてないので間違っているかもしれない)
import pandas as pd
import numpy as np
import statsmodels.api as sm
import matplotlib.pyplot as plt
from tqdm import tqdm # 進捗バー表示用
class ComomentumAnalyzer:
"""
Lou & Polk (2013) に基づく Comomentum (混雑度ファクター) を計算するクラス
"""
def __init__(self, prices_df, market_series, window_mom=252, window_corr=252):
"""
:param prices_df: 株価のデータフレーム (index: 日付, columns: 銘柄)
:param market_series: ベンチマーク(S&P500等)の価格シリーズ
:param window_mom: モメンタム計測期間 (例: 252営業日 = 1年)
:param window_corr: 相関計測期間 (例: 252営業日)
"""
self.prices = prices_df
self.market = market_series
self.returns = prices_df.pct_change().dropna(how='all')
self.market_returns = market_series.pct_change().dropna()
self.window_mom = window_mom
self.window_corr = window_corr
def _get_momentum_winners(self, date, top_n_percent=0.1):
"""
指定された日付時点でのモメンタム上位銘柄(Winner)を特定する
※ 12ヶ月リターン (直近1ヶ月除外) を使用するのが一般的
"""
# 指定日から過去の期間をスライス
past_prices = self.prices.loc[:date].iloc[-self.window_mom:]
if len(past_prices) < self.window_mom:
return []
# リターン計算(t-252 ~ t-21 : 直近1ヶ月をスキップする慣習)
lag = 21
mom_score = (past_prices.iloc[-lag] / past_prices.iloc[0]) - 1
# 上位N%を抽出
n_stocks = int(len(mom_score.dropna()) * top_n_percent)
if n_stocks < 2:
return []
winners = mom_score.nlargest(n_stocks).index.tolist()
return winners
def _calculate_residuals(self, stock_returns, market_returns):
"""
市場要因を除去した「残差」を計算する (CAPMモデル: r_i = alpha + beta * r_m + epsilon)
"""
residuals = pd.DataFrame(index=stock_returns.index, columns=stock_returns.columns)
# 市場リターンの定数項追加 (alpha用)
X = sm.add_constant(market_returns)
for stock in stock_returns.columns:
y = stock_returns[stock]
# 欠損除去して回帰
valid_idx = y.dropna().index.intersection(X.index)
if len(valid_idx) < len(market_returns) * 0.8: # データ不足はスキップ
continue
model = sm.OLS(y.loc[valid_idx], X.loc[valid_idx]).fit()
residuals.loc[valid_idx, stock] = model.resid
return residuals.astype(float)
def compute_history(self, rebalance_freq='ME'):
"""
時系列でComomentumを計算するメイン関数
:param rebalance_freq: 計算頻度 (例: 'ME' = 月末ごと)
"""
# dates = self.returns.resample(rebalance_freq).last().index
dates = self.returns.index
comomentum_history = []
valid_dates = []
print("Calculating Comomentum History...")
for date in tqdm(dates):
if date not in self.returns.index:
continue
# 1. その時点での「勝ち組(Winner)」を特定
winners = self._get_momentum_winners(date)
if not winners:
continue
# 2. Winner銘柄と市場の直近リターンを取得
window_start = date - pd.Timedelta(days=365) # 近似的に1年前
subset_returns = self.returns.loc[window_start:date, winners]
subset_market = self.market_returns.loc[window_start:date]
# データ長合わせ
common_idx = subset_returns.index.intersection(subset_market.index)
subset_returns = subset_returns.loc[common_idx]
subset_market = subset_market.loc[common_idx]
if len(subset_market) < self.window_corr * 0.8:
continue
# 3. 残差(Residuals)の計算 (直交化プロセス)
residuals = self._calculate_residuals(subset_returns, subset_market)
# 4. 残差間の相関行列を計算
corr_matrix = residuals.corr()
# 5. 対角成分を除いた平均相関を計算 (Comomentumスコア)
# 上三角行列の要素のみを取得
mask = np.triu(np.ones_like(corr_matrix, dtype=bool), k=1)
avg_corr = corr_matrix.where(mask).stack().mean()
comomentum_history.append(avg_corr)
valid_dates.append(date)
return pd.Series(comomentum_history, index=valid_dates, name='Comomentum')
# --- 以下、使用例(ダミーデータ生成) ---
def generate_dummy_data(n_stocks=50, n_days=1000):
"""検証用のランダムデータ生成"""
np.random.seed(42)
dates = pd.date_range(start='2020-01-01', periods=n_days, freq='B')
# 市場データ
market = pd.Series(np.cumprod(1 + np.random.normal(0.0005, 0.01, n_days)), index=dates)
# 個別株データ(市場連動 + 固有ノイズ)
prices_data = {}
for i in range(n_stocks):
beta = np.random.uniform(0.5, 1.5)
noise = np.random.normal(0, 0.02, n_days)
# わざとらしい「混雑」を作るため、一部期間に共通ノイズを混ぜる
if i < 10: # 一部の銘柄群
noise[500:600] += np.random.normal(0, 0.05, 100) # 暴れる期間
ret = (market.pct_change().fillna(0) * beta) + noise
prices_data[f'Stock_{i}'] = np.cumprod(1 + ret)
return pd.DataFrame(prices_data, index=dates), market
# --- 実行セクション ---
if __name__ == "__main__":
# 1. データ準備
prices, market = generate_dummy_data() # 本番は株式とTOPIXなどのインデックスデータを入れる。
# 2. インスタンス化と計算
analyzer = ComomentumAnalyzer(prices, market)
comomentum_series = analyzer.compute_history(rebalance_freq='BME') # 月次計算
# 3. プロット
plt.figure(figsize=(10, 6))
# --- 修正ポイント: データを日次に揃えて(Reindex)、前方の値を埋める(ffill) ---
# これにより、Comomentumも2020年1月からのデータ枠を持つ(値はNaNだが枠はある)
daily_comomentum = comomentum_series.reindex(market.index).ffill()
# 上段: Comomentum
ax1 = plt.subplot(2, 1, 1)
daily_comomentum.plot(ax=ax1, color='darkred', lw=2)
ax1.set_title('Comomentum Factor (Crowding Score)')
ax1.set_ylabel('Avg Pairwise Correlation of Residuals')
ax1.grid(True, alpha=0.3)
# 下段: 市場価格
ax2 = plt.subplot(2, 1, 2, sharex=ax1)
market.plot(ax=ax2, color='navy')
ax2.set_title('Market Index')
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
3. 検証と可視化:JQuants APIで検証
topixとTOPIX500でいい感じに計算した結果を載せます。手元で検証したい方はいい感じにやって見てください。2009年からの結果が欲しい人は、JQuants APIでやってみると良いかもしれません。
4. 考察とまとめ
結果の解釈
出力されたグラフを見ると、以下のことが確認できるはずです。
- 最初の1年は空白: Comomentumの計算には「過去1年間の相関」を利用しているため、データ蓄積期間(ウォームアップ)として空白になります。これは正常な挙動です。
- 2017~2021年: 0.1を超える期間が結構あります。 一方で、2022年以降は穏やかです。
これは私の適当な類推ですが、2017~2019年はアベノミクス、2020~2021年はコロナバブルではないかと考えています。一方で2020年の最初の方にComomentumが小さくなっている期間がありますよね?この時期にコロナショックが起こっているので、資金の流入が行われなくなったのではないかと思います。最後に、2022年以降は株価が上昇しているものの、Comomentumは小さいままです。おそらく海外からお金が入ってきたか、投資家が勉強して賢くなったか、いずれにせよ、バブルではないとすると、マーケットが正常になっているように見ることが出来なくもないです。
最近暗い話が多いですが、意外と日本は良い方向に向かっているのかもしれません。このことは知ってもらいたいと思い、あまり公開したくない内容ですが、今回の記事にすることにしました。即興で書いたので計算間違っていたらすみません。確かめたい方はJQuants APIなりで自分で計算してみてください。
まとめ
- **Comomentum**とは、ファンダメンタルズを超えた「過剰な連動」のこと。
- 正しく検知するには、市場ベータを除去した**「残差の相関」**を見る必要がある。
- 昨今の日本市場はバブルではないのかもしれない。
このファクターは、リターンを稼ぐための「攻めの指標」というよりは、**「いつ逃げるべきか」を教えてくれる「守りの指標」**として極めて優秀です。
皆さんのポートフォリオも、気づかないうちに「満員の映画館」になっていないか、一度Pythonでチェックしてみてはいかがでしょうか。
参考文献
- Khandani, A. E., & Lo, A. W. (2007). What Happened to the Quants in August 2007?
- Lou, D., & Polk, C. (2013). Comomentum: Inferring Arbitrage Activity from Return Correlations
Discussion