ファクターモメンタム戦略は有効か?ファーマ=フレンチ3因子モデルのデータでPython検証

投資・ファイナンス

はじめに

ファクターモメンタムとは、過去に高いパフォーマンスを示したファクター(因子)を、今後もパフォーマンスが続くと仮定して投資判断に用いる戦略です。

本記事では、ファーマ=フレンチの有名な3因子(Mkt-RF、SMB、HML)データを用いて、どの因子がどのタイミングで強かったか?そのモメンタムに追随すべきか? を検証します。

データ取得と前処理

まずはQuantechiaライブラリを使ってファーマ=フレンチの因子データを取得します。

from quantechia.factor import fama_french

# データ取得
data = fama_french.get_ff()

# 無リスク金利(RF)は除外
del data['RF']

# データ確認
data.head()

このデータには、以下の因子が含まれます:

  • Mkt-RF: 市場リスクプレミアム(マーケット全体の超過リターン)
  • SMB: サイズ因子(Small Minus Big、小型株効果)
  • HML: バリュー因子(High Minus Low、割安株効果)
  • RMW: 収益性因子(Robust Minus Weak、利益率が高い企業の超過リターン)
  • CMA: 投資スタイル因子(Conservative Minus Aggressive、保守的に投資する企業の超過リターン)

月次リターン(%)で提供されているので、日付をインデックスとして使えるようにしておきましょう。

因子ごとのモメンタム(直近リターン)を計算

過去のリターンが高かった因子が、翌月も高いリターンを示すかを検証します。
まずは、過去12ヶ月の累積リターンを「モメンタムスコア」として算出します。

import pandas as pd

# 月次リターン→収益率に変換(%→小数)
returns = data / 100

# 12ヶ月モメンタム(累積リターン)
momentum_score = (1 + returns).rolling(window=12).apply(lambda x: x.prod() - 1)

# 翌月のリターン(ターゲット)
next_month_return = returns.shift(-1)

モメンタムスコアと翌月リターンの相関分析

from scipy.stats import pearsonr

# 結果を格納する辞書
results = {}

for factor in momentum_score.columns:
    # 欠損値を除去(rollingでNaNが出るため)
    valid_idx = momentum_score[factor].notnull() & next_month_return[factor].notnull()

    x = momentum_score[factor][valid_idx]
    y = next_month_return[factor][valid_idx]

    corr, pval = pearsonr(x, y)
    results[factor] = {'correlation': corr, 'p_value': pval}

# 結果をDataFrameで表示
import pandas as pd

result_df = pd.DataFrame(results).T
print("モメンタムスコアと翌月リターンの相関・P値:")
print(result_df)

この相関が正であれば、「モメンタムが効いている」と言えます。

Mkt以外についてはややモメンタム性があるかもしれません。

モメンタム戦略ポートフォリオのパフォーマンス

モメンタムスコアが最も高い因子に毎月1つだけ投資した場合のリターンを検証します。

from quantechia.strategy import basestrategy
import numpy as np


class MomentumStrategy(basestrategy.BaseStrategy):
    def calculate_weight(self) -> pd.DataFrame:
        # モメンタムスコア:過去12ヶ月の累積リターン
        momentum_score = (1 + self.rtn_data).rolling(window=12).apply(np.prod, raw=True) - 1

        # 各月、最もスコアが高い因子を選択
        best_factor = momentum_score.idxmax(axis=1)

        # 各月の選択因子をワンホット形式(= 投資ウェイト)に変換
        weights = pd.get_dummies(best_factor).astype(float)

        return weights
index_data = (1+data/100).cumprod()
index_data.index = index_data.index.to_timestamp(how='end').date
index_data.index = pd.to_datetime(index_data.index)
fmom = MomentumStrategy(index_data)
fmom.evaluate(display_mode='full', benchmark=index_data[['Mkt-RF']].pct_change())

Mkt-RFよりは高いシャープレシオでした。

どの期間のモメンタムがよいのか

import pandas as pd

# 結果を格納するリスト
results = []

# モメンタム期間の候補(例:1, 3, 6, 9, 12, 18, 24, 52, 120ヶ月)
window_list = [1, 3, 6, 9, 12, 18, 24, 52, 120]

for window in window_list:


    # 戦略インスタンス化&評価
    strat = MomentumStrategy(index_data, window=window)
    stats = strat.evaluate()

    # 結果にrollingウィンドウを追加
    stats['window'] = window
    results.append(stats)

# データフレーム化して比較
performance_df = pd.DataFrame(results).set_index('window')
performance_df

今回の条件では1か月と12か月がよさそうです。
ただし、比較の期間が異なっている点には注意が必要です。

リスクリターンの場合

単純なリターンの比較ではなく、リスク対比でリターンの高いものを選択するようにします。

class MomentumStrategyRR(basestrategy.BaseStrategy):
    def __init__(self, price_data: pd.DataFrame = None, rtn_data: pd.DataFrame = None,window=12, strategy_name: str = None, initial_capital: float = 1, shift_num: int = 1, cost: bool = True, cost_unit: float = 0.0005):
        super().__init__(price_data, rtn_data, strategy_name, initial_capital, shift_num, cost, cost_unit)
        self.window = window
    def calculate_weight(self) -> pd.DataFrame:
        # モメンタムスコア:過去12ヶ月の累積リターン
        rolling_mean = self.rtn_data.rolling(window=self.window).mean()
        rolling_std = self.rtn_data.rolling(window=max(10,self.window)).std()
        momentum_score = rolling_mean / rolling_std  # ここがr/r

        # 各月、最もスコアが高い因子を選択
        # モメンタムスコアのNaN行を除外して、best_factorを算出
        valid_score = momentum_score.dropna(how='all')
        best_factor = valid_score.idxmax(axis=1)


        # 各月の選択因子をワンホット形式(= 投資ウェイト)に変換
        weights = pd.get_dummies(best_factor).astype(float)

        return weights

import pandas as pd

# 結果を格納するリスト
results = []

# モメンタム期間の候補(例:1, 3, 6, 9, 12, 18, 24, 52, 120ヶ月)
window_list = [1, 3, 6, 9, 12, 18, 24, 52, 120]

for window in window_list:


    # 戦略インスタンス化&評価
    strat = MomentumStrategyRR(index_data, window=window)
    stats = strat.evaluate()

    # 結果にrollingウィンドウを追加
    stats['window'] = window
    results.append(stats)

# データフレーム化して比較
performance_df = pd.DataFrame(results).set_index('window')
performance_df


18か月のシャープレシオはよくなりましたが、悪化したものの多めです。

ランキングウェイト

続いて、最もモメンタムスコアが高い1銘柄を選択するのではなく、ランキング形式で、モメンタムスコアが高いものほどウェイトが高くなるようにします。

class MomentumStrategyRank(basestrategy.BaseStrategy):
    def __init__(self, price_data: pd.DataFrame = None, rtn_data: pd.DataFrame = None,window=12, strategy_name: str = None, initial_capital: float = 1, shift_num: int = 1, cost: bool = True, cost_unit: float = 0.0005):
        super().__init__(price_data, rtn_data, strategy_name, initial_capital, shift_num, cost, cost_unit)
        self.window = window
    def calculate_weight(self) -> pd.DataFrame:
        # モメンタムスコア:過去12ヶ月の累積リターン
        rolling_mean = self.rtn_data.rolling(window=self.window).mean()
        rolling_std = self.rtn_data.rolling(window=max(10,self.window)).std()
        momentum_score = rolling_mean / rolling_std  # ここがr/r

        # NaNがある月を除外
        valid_score = momentum_score.dropna(how='all')

        # ランキング付け(大きいほど順位が高い=スコアが良い)
        ranks = valid_score.rank(axis=1, ascending=False, method='min')

        # 各月のランクを正規化してウェイトに(合計1になるように)
        weights = ranks.div(ranks.sum(axis=1), axis=0)


        return weights

import pandas as pd

# 結果を格納するリスト
results = []

# モメンタム期間の候補(例:1, 3, 6, 9, 12, 18, 24, 52, 120ヶ月)
window_list = [1, 3, 6, 9, 12, 18, 24, 52, 120, 240]

for window in window_list:


    # 戦略インスタンス化&評価
    strat = MomentumStrategyRank(index_data, window=window)
    stats = strat.evaluate()

    # 結果にrollingウィンドウを追加
    stats['window'] = window
    results.append(stats)

# データフレーム化して比較
performance_df = pd.DataFrame(results).set_index('window')
performance_df

計測期間の違いもありまずが、長期でR/Rを計算するほうがシャープレシオが高くなっている傾向があります。

R/Rに基づくウェイト

ランキング化を行わずr/rをそのままウェイトとして使った場合も同様の結果になりました。

class MomentumStrategyRRweight(basestrategy.BaseStrategy):
    def __init__(self, price_data: pd.DataFrame = None, rtn_data: pd.DataFrame = None,window=12, strategy_name: str = None, initial_capital: float = 1, shift_num: int = 1, cost: bool = True, cost_unit: float = 0.0005):
        super().__init__(price_data, rtn_data, strategy_name, initial_capital, shift_num, cost, cost_unit)
        self.window = window
    def calculate_weight(self) -> pd.DataFrame:
        # モメンタムスコア:過去12ヶ月の累積リターン
        rolling_mean = self.rtn_data.rolling(window=self.window).mean()
        rolling_std = self.rtn_data.rolling(window=max(10,self.window)).std()
        momentum_score = rolling_mean / rolling_std  # ここがr/r
        momentum_score = momentum_score.clip(-1.5,1.5)
        # NaNがある月を除外
        valid_score = momentum_score.dropna(how='all')


        # 各月のスコアをそのままウェイトに(絶対値の合計でスケーリングして合計=1に)
        weights = valid_score.div(valid_score.abs().sum(axis=1), axis=0)


        return weights

import pandas as pd

# 結果を格納するリスト
results = []

# モメンタム期間の候補(例:1, 3, 6, 9, 12, 18, 24, 52, 120ヶ月)
window_list = [1, 3, 6, 9, 12, 18, 24, 52, 120, 240]

for window in window_list:


    # 戦略インスタンス化&評価
    strat = MomentumStrategyRRweight(index_data, window=window)
    stats = strat.evaluate()

    # 結果にrollingウィンドウを追加
    stats['window'] = window
    results.append(stats)

# データフレーム化して比較
performance_df = pd.DataFrame(results).set_index('window')
performance_df

2000年以降のリターンに絞って、すべての戦略期間を一致させた場合の結果が以下です。

組み合わせ

R/Rに基づくウェイトに直近1か月リターンが高いものの重みづけは大きくなる補正を加えます。

class MomentumStrategyLongShort(basestrategy.BaseStrategy):
    def __init__(self, price_data: pd.DataFrame = None, rtn_data: pd.DataFrame = None,window=12,alpha=1, strategy_name: str = None, initial_capital: float = 1, shift_num: int = 1, cost: bool = True, cost_unit: float = 0.0005):
        super().__init__(price_data, rtn_data, strategy_name, initial_capital, shift_num, cost, cost_unit)
        self.window = window
        self.alpha = alpha
    def calculate_weight(self) -> pd.DataFrame:
        # モメンタムスコア(r/r)
        rolling_mean = self.rtn_data.rolling(window=self.window).mean()
        rolling_std = self.rtn_data.rolling(window=max(10, self.window)).std()
        rr_score = rolling_mean / (rolling_std + 1e-8)
        rr_score = rr_score.clip(0, 1.5)

        # 直近1ヶ月のリターンを取得
        recent_return = self.rtn_data.shift(1)

        # NaN行を除外してトップファクターを取得
        top_factors = recent_return.dropna(how='all').idxmax(axis=1)

        # 補正マスクを作成
        bonus = pd.DataFrame(0, index=rr_score.index, columns=rr_score.columns)
        for date, factor in top_factors.items():
            if factor in bonus.columns:
                bonus.at[date, factor] = 1


        # 補正スコアの加算(ボーナスを加える)

        adjusted_score = rr_score + self.alpha * bonus

        # スコアの合計が±1になるようにスケーリング
        valid_score = adjusted_score.dropna(how='all')
        weights = valid_score.div(valid_score.abs().sum(axis=1), axis=0)



        return weights



import pandas as pd

# 結果を格納するリスト
results = []

# モメンタム期間の候補(例:1, 3, 6, 9, 12, 18, 24, 52, 120ヶ月)
window_list = [1, 3, 6, 9, 12, 18, 24, 52, 120, 240]

for window in window_list:


    # 戦略インスタンス化&評価
    strat = MomentumStrategyLongShort(index_data, window=window, alpha=0.2)
    stats = strat.evaluate()

    # 結果にrollingウィンドウを追加
    stats['window'] = window
    results.append(stats)

# データフレーム化して比較
performance_df = pd.DataFrame(results).set_index('window')
performance_df

全体的には改善傾向に見えますが、長期の部分はそのままのほうがよさそうです

以下ではリスクパリティとモメンタムを組み合わせてみます。

今回のコードはこちら

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