「暴落待ち」は機会損失?S&P500過去30年のデータ検証が示す「即時積立」の優位性

投資の機会損失をテーマにしたパステルカラーのフラットイラスト。左側は「暴落待ち」を象徴し、駅のホームで考え込み、上昇する市場を表す高速列車に乗り遅れる人物を描写。右側は「即時積立」を象徴し、すでに列車に乗ってリラックスし、車窓から右肩上がりの風景を眺めながら資産の芽を育てる人物を描き、市場に参加し続けることの優位性を表現している。 投資戦略

序章:投資における「待機」は、賢明な判断か?

「今は最高値圏だから、少し調整(暴落)してから買いたい」

投資を始めようとする時、あるいは追加資金を投入する時、誰しもがこう考えます。安く買いたいというのは人間の自然な心理であり、直感的には「賢い戦略」のように思えます。

しかし、私は現役のエンジニアとして、この「待機」という行動に強い違和感を覚えます。なぜなら、システム運用の世界において、リソース(資金)が稼働していない時間は「アイドルタイム(無駄な空き時間)」以外の何物でもないからです。

この記事では、個人の「相場観」や「感情」を一切排除し、過去30年のデータとアルゴリズム検証のみを用いて、「暴落を待つこと」が資産形成において合理的かどうかを判定します。

結論から言えば、「暴落待ちは、バグを含んだ非効率なアルゴリズム」です。その理由を、数字で証明しましょう。


【事実】過去30年のS&P500データ検証:「待った人」の末路

まずは、現実の市場で何が起きたのかを確認します。世界で最も代表的な指数「S&P500」の過去30年(1994年〜2023年)のデータを用い、以下の3人をシミュレーションで戦わせてみました。

  • Aさん(即時積立):毎月1,000ドルを、何も考えずに機械的に積み立てる。
  • Bさん(10%下落待ち):毎月の資金を現金でプールし、直近高値から「10%暴落」したタイミングでのみ、プール金を全額投入する。
  • Cさん(20%下落待ち):さらに慎重に、「20%暴落」するまでひたすら待ち続ける。

バックテスト結果:「慎重な人」ほど資産を減らすパラドックス

直感では、大暴落を狙い撃ちしたCさんが勝ちそうに思えます。しかし、Pythonによるバックテスト結果は、その直感を完全に否定しました。

S&P 500の過去およそ30年間(1994-2025)における投資シミュレーション比較グラフ。青線の「即時積立」が最も高い最終資産額を示し、オレンジ線の「10%下落待ち」、緑線の「20%下落待ち」と、暴落を待つ戦略ほど資産が増えていない結果を可視化している。
  • アルゴリズムA(即時積立): $1,687,775
  • アルゴリズムB(10%下落待ち): $1,483,441
  • アルゴリズムC(20%下落待ち): $1,434,272

即時積立と20%下落待ちの差は、約25万ドル(現在のレートで約3,600万円以上)。

「より安く買おう」と粘ったCさんが、一番資産を増やせませんでした。グラフを見ても、青い線(即時)が一貫してリードし、暴落を待つ2本の線は、一時的に安く買えたとしても、その後の回復局面で現金を抱えたまま置いていかれていることが分かります。

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import yfinance as yf
import os

# 日本語表示のためのライブラリをインストール
try:
    import japanize_matplotlib
except ImportError:
    os.system('pip install japanize-matplotlib')
    import japanize_matplotlib

def run_simulation(threshold=0.10, monthly_investment=1000):
    """
    threshold: 暴落とみなす下落率(0.10 = 10%)
    monthly_investment: 毎月の積立額
    """
    # S&P500のデータを取得 (約30年間)
    data = yf.download('^GSPC', start='1994-01-01', end='2024-12-31', progress=False)
    
    # データの列構造を整理(MultiIndex対策)
    if isinstance(data.columns, pd.MultiIndex):
        close_prices = data['Close'].iloc[:, 0]
    else:
        close_prices = data['Close']
    
    # 月次の終値を取得し、明示的にDataFrame化
    monthly_data = close_prices.resample('MS').last().to_frame()
    monthly_data.columns = ['Close']
    
    # アルゴリズムA: 即時積立 (DCA)
    monthly_data['Shares_A'] = monthly_investment / monthly_data['Close']
    monthly_data['Total_Shares_A'] = monthly_data['Shares_A'].cumsum()
    monthly_data['Value_A'] = monthly_data['Total_Shares_A'] * monthly_data['Close']
    
    # アルゴリズムB: 暴落待ち
    monthly_data['Running_Max'] = monthly_data['Close'].cummax()
    monthly_data['Drawdown'] = (monthly_data['Close'] - monthly_data['Running_Max']) / monthly_data['Running_Max']
    
    shares_b = 0
    total_shares_b_list = []
    cash_pool = 0
    
    for i in range(len(monthly_data)):
        cash_pool += monthly_investment
        # 直近高値から閾値以上下落したか判定
        if monthly_data['Drawdown'].iloc[i] <= -threshold:
            shares_bought = cash_pool / monthly_data['Close'].iloc[i]
            shares_b += shares_bought
            cash_pool = 0
        
        total_shares_b_list.append(shares_b)
    
    monthly_data['Total_Shares_B'] = total_shares_b_list
    monthly_data['Value_B'] = (monthly_data['Total_Shares_B'] * monthly_data['Close']) + cash_pool
    
    return monthly_data

# シミュレーション実行
df_10 = run_simulation(threshold=0.10)
df_20 = run_simulation(threshold=0.20)

# 可視化
plt.figure(figsize=(12, 7))
plt.plot(df_10.index, df_10['Value_A'], label='アルゴリズムA: 即時積立', color='#1f77b4', linewidth=2)
plt.plot(df_10.index, df_10['Value_B'], label='アルゴリズムB: 10%下落待ち', color='#ff7f0e', linestyle='--')
plt.plot(df_20.index, df_20['Value_B'], label='アルゴリズムB: 20%下落待ち', color='#2ca02c', linestyle=':')

plt.title('S&P 500 バックテスト: 即時積立 vs 暴落待ち (30年間)', fontsize=15)
plt.xlabel('年', fontsize=12)
plt.ylabel('資産評価額 ($)', fontsize=12)
plt.legend()
plt.grid(True, which='both', linestyle='--', alpha=0.5)
plt.tight_layout()

# 結果の表示
print(f"--- 最終資産評価額 (30年後) ---")
print(f"アルゴリズムA (即時積立): ${df_10['Value_A'].iloc[-1]:,.0f}")
print(f"アルゴリズムB (10%下落待ち): ${df_10['Value_B'].iloc[-1]:,.0f}")
print(f"アルゴリズムB (20%下落待ち): ${df_20['Value_B'].iloc[-1]:,.0f}")

plt.show()

なぜ「安く買った」のに負けるのか?

この敗因は、エンジニアリングで言う「スループット(処理能力)の低下」です。

グラフの線が「横ばい」になっている期間、それは「暴落まだかな」と現金を握りしめて待っている「アイドルタイム」です。この間、S&P500は配当を生み出し、成長を続けていました。

上昇相場における暴落待ちの矛盾を示す概念図。株価が現在の100から120へ上昇した後、10%下落して108になった様子を描写。「暴落を待って買った価格(108)」が「今すぐ買った価格(100)」よりも高くなり、待機が機会損失につながる仕組みを視覚的に説明している。
  1. 株価100の時に「高いから待とう」と判断する。
  2. 株価が120まで上昇する。
  3. そこから10%暴落して108になる。
  4. 「やった!安くなった!」と108で買う。

これが待機戦略の正体です。今の100で買わず、将来の108で喜んで買っている。 この矛盾こそが、資産形成の効率を劇的に悪化させる最大のバグなのです。


【理論】モンテカルロ検証:待てば待つほど「期待値」は消える

「今回はたまたまそうなっただけでは?」 「もっと上手く立ち回れば勝てるのでは?」

その疑問に答えるため、今度は「モンテカルロシミュレーション」を行いました。数千パターンの架空の市場シナリオを生成し、どのような相場環境でも通用する「法則」なのかを検証します。

📊 補足:モンテカルロシミュレーションとは?

一言で言えば、コンピュータ上で「1,000通りのパラレルワールド(あり得たかもしれない別の歴史)」を作り出し、その全てでテストを行う実験のことです。

過去30年のS&P500のデータは、あくまで「たまたま起きた1つの歴史」に過ぎません。「もしあの時、暴落が起きなかったら?」「もしもっと激しい暴落が起きていたら?」という「もしも」の世界では、結果が変わっていたかもしれません。

そこで、数学的な乱数を用いて「架空の相場」を何千回も生成し、その平均をとることで、運や特定の時代背景に左右されない「確率的に最も確からしい答え」を導き出します。

検証1:資産推移のばらつき(チャート確認)

まずは、1,000回の試行における資産推移の軌跡を見てみましょう。

1000回のモンテカルロシミュレーションによる資産推移チャート。多数のシナリオの中で、青色の太線で示された「即時積立の平均値」が、オレンジ色の太線「暴落待ちの平均値」を一貫して上回り、時間の経過とともにその差が拡大していく統計的な傾向を示している。

このチャートが示しているのは、「時間の経過とともに、即時積立(青)と暴落待ち(オレンジ)の平均的な差が開いていく」という事実です。 特定のラッキーなパターンだけでなく、統計的な平均値として「待機戦略」は即時積立に対して劣後し続けることが、この無数の線の集まりから読み取れます。

検証2:感度分析(棒グラフ確認)

さらに、「待機する下落率(トリガー)」を細かく変化させた場合、最終リターンがどう変化するかを棒グラフで確認します。

待機する下落率と平均最終資産額の関係を示す棒グラフ(モンテカルロシミュレーション結果)。横軸の「待機する下落率」が0%(即時)、10%、20%、30%と大きくなるにつれて、縦軸の「平均最終資産額」が右肩下がりに減少していく様子を表し、待つほど期待リターンが低下する法則を示している。

グラフは綺麗な「右肩下がり」を示しています。

  • 0%(即時積立):リターン最大
  • 10%待ち:リターン減少
  • 20%待ち:さらに減少
  • 30%待ち:さらに大きく減少

より深い暴落を待てば、「平均取得単価」を下げることに成功した気になるかもしれません。しかし、それは単なる思い込みであり、トータルの成績(最終資産額)だけではなく、実は平均取得単価においてさえも即時積立に劣る場合がほとんどです。「スループットの低下」の章で示した通り、上昇相場では「暴落後の安値」でさえ「見送った過去の価格」より高いからです。安く買うメリットがないどころか、市場に参加できない期間(アイドルタイム)が長引き、複利の効果を殺してしまうデメリットの方がはるかに大きいのです。

この分析から導き出されるエンジニアリング的な結論は一つです。

「投資において、エントリーのタイミングを最適化しようとする努力(パラメータ調整)は、システム全体のリターンに対して逆効果である」

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import os

# 日本語表示のためのライブラリ設定
try:
    import japanize_matplotlib
except ImportError:
    os.system('pip install japanize-matplotlib')
    import japanize_matplotlib

# --- シミュレーション設定 ---
np.random.seed(42) # 結果の再現性のため
n_simulations = 1000  # 試行回数
n_years = 30
n_months = n_years * 12
dt = 1/12
mu = 0.07     # 年率期待リターン 7%
sigma = 0.20  # 年率ボラティリティ 20%
monthly_inv = 10000 # 毎月の積立額
initial_price = 100

# --- 幾何ブラウン運動による株価パス生成関数 ---
def generate_gbm_paths(n_sim, n_steps, dt, mu, sigma, s0):
    """
    幾何ブラウン運動に基づいて多数の株価シナリオを生成する
    """
    # 正規乱数の生成
    z = np.random.normal(0, 1, (n_steps, n_sim))
    # 株価の変動率計算
    drift = (mu - 0.5 * sigma**2) * dt
    diffusion = sigma * np.sqrt(dt) * z
    returns = np.exp(drift + diffusion)
    
    # 初期価格から累積してパスを生成
    price_paths = np.vstack([np.ones((1, n_sim)) * s0, s0 * returns.cumprod(axis=0)])
    return price_paths

# 株価パス生成実行
price_paths = generate_gbm_paths(n_simulations, n_months, dt, mu, sigma, initial_price)


# --- 投資戦略シミュレーション関数 ---
def simulate_strategy(price_paths, trigger_threshold):
    """
    指定された暴落待ちトリガーに基づいて投資結果を計算する
    trigger_threshold: 0.10 なら直近高値から10%下落で買付。0.0 は即時積立。
    """
    n_steps, n_sim = price_paths.shape
    # 資産評価額の推移を格納する配列
    portfolio_values = np.zeros((n_steps, n_sim))
    
    for sim_idx in range(n_sim):
        prices = price_paths[:, sim_idx]
        shares = 0
        cash_pool = 0
        running_max = prices[0]
        
        for t in range(n_steps):
            # 月初の資金追加
            if t > 0: # 初月は除外(データ構造の都合)
                cash_pool += monthly_inv
            
            current_price = prices[t]
            running_max = max(running_max, current_price)
            drawdown = (current_price - running_max) / running_max
            
            # 買付判定 (トリガーが0、または下落率がトリガーに達した時)
            # trigger_thresholdが0の場合は常にTrueとなり即時積立となる
            is_trigger_hit = (trigger_threshold == 0.0) or (drawdown <= -trigger_threshold)
            
            if is_trigger_hit and cash_pool > 0:
                shares_bought = cash_pool / current_price
                shares += shares_bought
                cash_pool = 0 # 全額投入
                # 暴落待ちで買付後は高値をリセットする戦略も考えられるが、
                # ここでは単純に直近高値からの下落を基準とする(より一般的な定義)
            
            # その時点の評価額計算
            portfolio_values[t, sim_idx] = (shares * current_price) + cash_pool
            
    return portfolio_values

# --- 検証1: 時系列パスの重ね書き (即時 vs 20%下落待ち) ---
print("検証1: 時系列シミュレーションを実行中...")
res_immediate = simulate_strategy(price_paths, trigger_threshold=0.0)
res_wait_20 = simulate_strategy(price_paths, trigger_threshold=0.20)

# 可視化 (検証1)
fig, ax = plt.subplots(figsize=(12, 7))
# 最初の50パスだけ半透明で表示してばらつきを示す
for i in range(50):
    ax.plot(res_immediate[:, i] / 10000, color='#1f77b4', alpha=0.1) # 単位を万円に
    ax.plot(res_wait_20[:, i] / 10000, color='#ff7f0e', alpha=0.1)

# 平均推移を太線で表示
ax.plot(np.mean(res_immediate, axis=1) / 10000, color='#1f77b4', linewidth=3, label='即時積立(平均)')
ax.plot(np.mean(res_wait_20, axis=1) / 10000, color='#ff7f0e', linewidth=3, linestyle='--', label='20%下落待ち(平均)')

ax.set_title('モンテカルロ資産推移比較: 即時積立 vs 20%下落待ち (1000シナリオ)', fontsize=15)
ax.set_xlabel('経過月数', fontsize=12)
ax.set_ylabel('資産評価額 (万円)', fontsize=12)
ax.legend()
ax.grid(True, linestyle='--', alpha=0.5)
plt.tight_layout()
plt.show()


# --- 検証2: 暴落待ちトリガーと期待リターンの関係 ---
print("\n検証2: トリガー感度分析を実行中...")
triggers = np.arange(0.0, 0.35, 0.05) # 0%から30%まで5%刻み
avg_final_values = []

for tr in triggers:
    res = simulate_strategy(price_paths, trigger_threshold=tr)
    # 最終時点(30年後)の全シナリオ平均評価額
    avg_final = np.mean(res[-1, :])
    avg_final_values.append(avg_final)

# 基準(即時積立)に対する比率を計算
baseline_value = avg_final_values[0]
relative_performance = [val / baseline_value for val in avg_final_values]

# 可視化 (検証2)
fig, ax1 = plt.subplots(figsize=(10, 6))

# 左軸: 平均最終資産額
color_bar = '#2ca02c'
bars = ax1.bar(triggers * 100, np.array(avg_final_values) / 10000, width=3, color=color_bar, alpha=0.7, label='平均最終資産額')
ax1.set_xlabel('待機する下落率トリガー (%)', fontsize=12)
ax1.set_ylabel('平均最終資産額 (万円)', fontsize=12, color=color_bar)
ax1.tick_params(axis='y', labelcolor=color_bar)
ax1.set_xticks(triggers * 100)

# 数値ラベルを追加
for bar in bars:
    height = bar.get_height()
    ax1.annotate(f'{height:,.0f}万',
                 xy=(bar.get_x() + bar.get_width() / 2, height),
                 xytext=(0, 3), color=color_bar, fontweight='bold',
                 textcoords="offset points", ha='center', va='bottom')

# 右軸: 即時積立に対するパフォーマンス比
ax2 = ax1.twinx()
color_line = '#d62728'
ax2.plot(triggers * 100, np.array(relative_performance) * 100, color=color_line, marker='o', linewidth=2, label='対 即時積立比率')
ax2.set_ylabel('即時積立に対する期待値 (%)', fontsize=12, color=color_line)
ax2.tick_params(axis='y', labelcolor=color_line)
ax2.set_ylim(bottom=80, top=105) # 見やすくするために範囲調整

plt.title('「待てば待つほど資産は減る」:下落待ちトリガーと期待リターンの関係', fontsize=15)
plt.grid(True, linestyle='--', alpha=0.5, axis='x')
plt.tight_layout()
plt.show()

結論:感情を捨て、システム(積立)を信じよう

今回の検証で、2つの事実が明らかになりました。

  1. 歴史的事実:過去30年、暴落待ちは即時積立に完敗し、20%待ちなどの慎重派ほど損失(機会損失)が大きかった。
  2. 統計的構造:安値を待てば待つほど、期待リターンは確実に低下するという法則性がある。

「暴落を待つ」という判断は、一見慎重で賢いように見えますが、実は「上昇相場に乗れない」という最大のリスクを許容する、非常にギャンブル性の高い行為です。

明日からできる「合理的」投資アクション

相場の変動に一喜一憂するのは、今日で終わりにしましょう。私たちに必要なのは、予測ではなく「仕様」への準拠です。

  • 現在持っている待機資金(余剰資金): 心理的に許容できる範囲で、可能な限り速やかに市場へ投入する。分割するにしても、期間をいたずらに延ばさない。
  • これからの給与収入: 「安い時に買おう」という色気を出さず、毎月定額を淡々と自動積立設定にする。

「市場に居続けること(Time in the Market)」こそが、タイミングを読むこと(Timing the Market)に勝る唯一の、そして最強の戦略です。

今日という日は、あなたの残りの人生で、最も長く複利を使える日です。さあ、資産形成のシステムを稼働させましょう。

コメント

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