「3連騰したら買い」は本当か?自己相関とランダムウォーク仮説をPythonで確かめてみた

基礎知識・戦略

「3日続けて上がったら、そろそろ下がる」「上がり始めたら乗っていけ」——相場にはこの手の経験則があふれています。しかも困ったことに、この2つは正反対のことを言っているのに、どちらももっともらしく聞こえます。

だとすれば、確かめればいいわけです。昨日の値動きは、今日の値動きを予測する材料になるのか。これを数値で測るのが自己相関です。

この記事では、Pythonで日本株の自己相関を計算し、「3連騰したら買い」という経験則が統計的に成立するのかを実データで検証します。あわせて、ランダムウォーク仮説の検定手法まで扱います。

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

自己相関は「昨日と今日の関係」を測る

自己相関とは、同じ系列を時間だけずらして、自分自身との相関を取ったものです。1日ずらして相関を計算すれば「昨日の動きと今日の動きの関係」が分かります。

自己相関意味相場での解釈有効な戦略
プラス同じ方向が続く上がった翌日は上がりやすい順張り・トレンドフォロー
ゼロ関係なし昨日は何の手がかりにもならないどちらも無効
マイナス反転しやすい上がった翌日は下がりやすい逆張り・平均回帰

ここで注意したいのは、「株価」ではなく「リターン(変化率)」で計算するという点です。株価そのものは昨日と今日でほぼ同じ値なので、自己相関を取れば当然0.99などという値が出ます。それは「株価は連続している」という当たり前の事実を言っているだけで、何の予測力もありません。

この違いは、意外と多くの人がつまずくところです。「株価の自己相関が0.99もある!予測できる!」という主張を見かけたら、それは「今日の株価は昨日の株価とだいたい同じ」と言っているだけだと思ってください。明日の株価が今日と近いことは誰でも知っています。知りたいのは「どちらに動くか」であり、それはリターンの自己相関でしか測れません。

そしてもう1つ、自己相関が測れるのは線形の関係だけだという限界も先に押さえておきます。「大きく下げた翌日だけ反発しやすい」というような条件付きの関係は、単純な自己相関では検出できません。この点は記事の後半で改めて触れます。

📘 外部参考Autocorrelation(Investopedia)ランダムウォーク仮説(Wikipedia)

日本株の自己相関を計算する

pip install yfinance pandas numpy statsmodels scipy matplotlib
import yfinance as yf
import numpy as np
import pandas as pd

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

print(f"検証期間: {ret.index[0].date()} 〜 {ret.index[-1].date()} "
      f"({len(ret):,}営業日)\n")
print(f"{'ラグ':>5s} {'自己相関':>10s}  解釈")
for lag in range(1, 11):
    ac = float(ret.autocorr(lag))
    mark = ""
    if abs(ac) > 2 / np.sqrt(len(ret)):     # おおよその有意水準
        mark = " ← 有意かもしれない"
    print(f"{lag:>4d}日 {ac:>10.4f}{mark}")

実行すると、ほとんどのラグで自己相関が±0.05程度という結果になります。ゼロではないものの、極めて小さい値です。

この「小さい」がどれくらい小さいのかを実感するために、相関係数の一般的な目安と比べてみます。

  • 0.7以上: 強い相関。片方から他方をかなり予測できる
  • 0.4前後: 中程度。傾向としては見える
  • 0.05: 実質的にほぼ無関係

つまり「昨日上がったから今日も上がる」も「そろそろ反転する」も、統計的にはほとんど根拠がないということになります。

コレログラムで全体像を見る

import matplotlib.pyplot as plt
from statsmodels.graphics.tsaplots import plot_acf

fig, axes = plt.subplots(2, 1, figsize=(11, 8))

plot_acf(ret, lags=40, ax=axes[0], alpha=0.05)
axes[0].set_title("日次リターンの自己相関(ACF)")
axes[0].set_ylim(-0.15, 0.15)      # 拡大しないと見えないほど小さい

plot_acf(ret.abs(), lags=40, ax=axes[1], alpha=0.05)
axes[1].set_title("リターンの絶対値の自己相関")

plt.tight_layout()
plt.savefig("autocorrelation.png", dpi=120)

2つのグラフを並べているのがポイントです。上のグラフ(リターンそのもの)はほぼすべてが信頼区間の中に収まり、予測できる要素がないことを示します。

ところが下のグラフ(リターンの絶対値)を見ると、話がまったく違います。何十日先まで、はっきりとプラスの自己相関が残るのです。

方向は読めないが、大きさは読める

ここが本記事でいちばん重要な発見です。株価データには、次の2つの性質が同時に存在します。

  • 符号(上がるか下がるか)はほぼランダム: 自己相関はほぼゼロ
  • 絶対値(どれだけ大きく動くか)は持続する: 自己相関が明確に残る
print(f"{'ラグ':>5s} {'リターン':>10s} {'絶対値':>10s} {'2乗':>10s}")
for lag in (1, 5, 10, 20, 40, 60):
    print(f"{lag:>4d}日 "
          f"{float(ret.autocorr(lag)):>10.4f} "
          f"{float(ret.abs().autocorr(lag)):>10.4f} "
          f"{float((ret ** 2).autocorr(lag)):>10.4f}")

これがボラティリティ・クラスタリングと呼ばれる現象です。「荒れた相場は荒れたまま続き、静かな相場は静かなまま続く」——値動きの方向は予測できなくても、値動きの大きさはかなり予測できるということです。

この事実には、実務上とても大きな意味があります。

できることできないこと
明日のリスクの大きさを見積もる明日上がるか下がるかを当てる
荒れた局面でポジションを減らす底や天井を当てる
損切り幅を相場環境に合わせる連騰から反転時期を読む

つまり予測ではなくリスク管理にこそ、統計の出番があるわけです。ボラティリティの持続性を利用したポジションサイズの調整は、標準偏差を使ってポジションサイズを決める方法で具体的に扱っています。

「3連騰したら買い」を検証する

いよいよ本題です。自己相関はほぼゼロでしたが、「3連騰」のような特定のパターンならどうかを直接確かめてみます。

def streak_analysis(returns, max_streak=5):
    """N連騰・N連敗の翌日リターンを集計する"""
    r = pd.Series(returns).dropna()
    up = (r > 0).astype(int)

    rows = []
    for n in range(1, max_streak + 1):
        # 直近n日すべてが上昇だった日を探す
        cond_up = up.rolling(n).sum() == n
        cond_down = up.rolling(n).sum() == 0

        # その翌日のリターン
        next_up = r.shift(-1)[cond_up].dropna()
        next_down = r.shift(-1)[cond_down].dropna()

        rows.append({
            "連続": f"{n}連騰",
            "件数": len(next_up),
            "翌日平均%": round(float(next_up.mean()) * 100, 4),
            "翌日勝率%": round(float((next_up > 0).mean()) * 100, 1),
        })
        rows.append({
            "連続": f"{n}連敗",
            "件数": len(next_down),
            "翌日平均%": round(float(next_down.mean()) * 100, 4),
            "翌日勝率%": round(float((next_down > 0).mean()) * 100, 1),
        })

    base = pd.DataFrame([{
        "連続": "(全体)", "件数": len(r),
        "翌日平均%": round(float(r.mean()) * 100, 4),
        "翌日勝率%": round(float((r > 0).mean()) * 100, 1),
    }])
    return pd.concat([base, pd.DataFrame(rows)], ignore_index=True)


print(streak_analysis(ret).to_string(index=False))

この表の読み方が大切です。「3連騰の翌日の勝率」を、全体の勝率と比べてください。差が数ポイント以内なら、それは連騰とは無関係のただのばらつきです。

実際に日経平均で計算すると、連騰・連敗のあとの翌日勝率は、全体の勝率とほとんど変わりません。差が出たとしても1〜2ポイント程度で、手数料を考えれば消えてしまう水準です。

その差は偶然ではないのか

「3連騰後の勝率が53%だった」という結果が出たとしても、それが偶然かどうかは検定しないと分かりません。

from scipy import stats

def test_streak(returns, n=3, direction="up"):
    """N連騰(連敗)後の翌日リターンが、通常と違うかを検定する"""
    r = pd.Series(returns).dropna()
    up = (r > 0).astype(int)

    cond = (up.rolling(n).sum() == n) if direction == "up" \
        else (up.rolling(n).sum() == 0)
    after = r.shift(-1)[cond].dropna()
    others = r.shift(-1)[~cond].dropna()

    t_stat, p_value = stats.ttest_ind(after, others, equal_var=False)

    label = f"{n}{'連騰' if direction == 'up' else '連敗'}"
    print(f"--- {label}後 ---")
    print(f"  該当日数     : {len(after):>7,d}")
    print(f"  翌日平均     : {float(after.mean()) * 100:>7.4f} %")
    print(f"  それ以外平均 : {float(others.mean()) * 100:>7.4f} %")
    print(f"  p値          : {float(p_value):>7.4f}")
    print("  → 差は偶然の範囲内です" if p_value >= 0.05
          else "  → 統計的な差がありそうです")


for n in (2, 3, 4):
    test_streak(ret, n, "up")
    test_streak(ret, n, "down")

ほとんどのケースでp値は0.05を大きく上回ります。つまり「3連騰したら買い」も「3連敗したら反発」も、統計的な裏づけは得られないという結論になります。

なお、ここでは連続日数を2〜4、方向を2通り、計6パターン試しています。複数回試している以上、たまたま1つがp<0.05になっても喜んではいけません。この多重検定の問題はp値を知ってバックテストの見方が変わった話で詳しく扱っています。

ランダムウォーク仮説を検定する

個別のパターンではなく、「そもそも株価はランダムウォークなのか」を正面から検定する方法もあります。代表的なのが分散比検定です。

考え方は明快です。完全なランダムウォークなら、分散は時間に比例して増えるはずです。5日間の分散は、1日の分散の5倍になるはず——この関係が成り立つかを確かめます。

def variance_ratio(returns, k=5):
    """分散比検定: ランダムウォークなら比は1になる

    比 > 1 : トレンドが持続する傾向
    比 < 1 : 平均回帰する傾向
    """
    r = np.asarray(pd.Series(returns).dropna(), dtype=float)
    n = len(r)

    var_1 = r.var(ddof=1)
    # k日間の累積リターンの分散
    k_period = np.array([r[i:i + k].sum() for i in range(n - k + 1)])
    var_k = k_period.var(ddof=1)

    vr = var_k / (k * var_1)

    # 検定統計量(等分散を仮定した簡易版)
    phi = 2 * (2 * k - 1) * (k - 1) / (3 * k * n)
    z = (vr - 1) / np.sqrt(phi)
    p = 2 * (1 - stats.norm.cdf(abs(z)))

    return vr, float(z), float(p)


print(f"{'期間':>6s} {'分散比':>9s} {'z値':>8s} {'p値':>8s}  判定")
for k in (2, 5, 10, 20, 60):
    vr, z, p = variance_ratio(ret, k)
    if p >= 0.05:
        verdict = "ランダムウォークと矛盾しない"
    elif vr > 1:
        verdict = "トレンド持続の傾向"
    else:
        verdict = "平均回帰の傾向"
    print(f"{k:>5d}日 {vr:>9.4f} {z:>8.3f} {p:>8.4f}  {verdict}")

日経平均で試すと、多くの期間で分散比が1に近く、ランダムウォーク仮説を棄却できないという結果になります。短期では平均回帰、長期ではトレンド持続の傾向がわずかに見えることもありますが、いずれも決定的な差ではありません。

分散比検定が自己相関より優れている点は、複数期間の関係をまとめて見られることです。1日ラグ、2日ラグ……と個別に見ていくと、どこかで偶然有意になるものが出てきます。分散比なら「k日間全体としてどうか」を1つの数字で判定できます。

読み方をまとめておきます。

  • 分散比 > 1: 同じ方向が続きやすい。トレンドフォローに有利な構造
  • 分散比 ≒ 1: ランダムウォーク。方向に関する優位性はない
  • 分散比 < 1: 行き過ぎが戻りやすい。逆張りに有利な構造

ただし、この値が1からわずかに離れていたとしても、そこから取り出せる利益は往復コストに満たないことがほとんどです。「統計的にわずかな偏りがある」ことと「そこで稼げる」ことの間には、大きな隔たりがあります。

個別株なら違うのか

指数はランダムウォークに近くても、個別銘柄なら癖があるのではないか——当然の疑問です。実際に複数銘柄で比較してみます。

WATCHLIST = {
    "^N225": "日経平均", "7203.T": "トヨタ", "6758.T": "ソニーG",
    "6501.T": "日立", "9432.T": "NTT", "8035.T": "東エレク",
    "8306.T": "三菱UFJ", "JPY=X": "ドル円",
}

rows = []
for code, name in WATCHLIST.items():
    try:
        r = yf.download(code, period="10y", auto_adjust=True,
                        progress=False)["Close"].pct_change().dropna()
        if len(r) < 1000:
            continue
        vr5, _, p5 = variance_ratio(r, 5)
        rows.append({
            "銘柄": name,
            "AC(1日)": round(float(r.autocorr(1)), 4),
            "AC(5日)": round(float(r.autocorr(5)), 4),
            "絶対値AC(1日)": round(float(r.abs().autocorr(1)), 3),
            "分散比(5日)": round(float(vr5), 3),
            "RW棄却": "×" if p5 >= 0.05 else "○",
        })
    except Exception:
        continue

print(pd.DataFrame(rows).to_string(index=False))
print("\n※RW棄却が× = ランダムウォークと矛盾しない")

結果を見ると、どの銘柄も自己相関は±0.05程度に収まる一方で、絶対値の自己相関はどれも0.2〜0.3と明確に大きいという共通のパターンが浮かび上がります。

銘柄が変わっても構造は同じ、ということです。「この銘柄だけは癖がある」という期待は、たいてい裏切られます。

ドル円を入れているのにも理由があります。株式と為替では市場参加者も取引時間もまったく違いますが、それでも同じ構造が現れるのです。これは個別の市場の事情ではなく、多数の参加者が競争する市場そのものが持つ性質だということを示しています。

もし特定の銘柄で明確な自己相関が観測されたなら、次の可能性を疑ってください。

  • 流動性が低い: 売買が薄く、株価が滑らかに動かないだけ
  • 期間が短い: たまたまその期間だけそう見えている
  • データに問題がある: 株式分割の未調整や、値がつかなかった日の扱い

特に1番目は要注意です。流動性の低い銘柄で見つかる自己相関は、実際には「売買が成立しなかったせいで前日と同じ値がついた」ことの副産物であることが多く、そこに手を出しても約定しません。

では、なぜトレンドフォローは機能するのか

ここで矛盾を感じた方もいるはずです。自己相関がゼロなら、トレンドフォロー戦略が成立する余地はないのでは?

これには、いくつかの説明が考えられます。

  • 線形の自己相関では捉えられない構造がある: 自己相関が測るのは線形の関係だけ。非線形の依存関係は検出できない
  • トレンドフォローの本質は損益の非対称性: 勝率ではなく「損小利大」で稼いでいる
  • ボラティリティの持続性を間接的に利用している: 荒れた相場で大きく取れる構造になっている

特に2番目が重要だと考えています。トレンドフォローは勝率40%でも成立する戦略です。方向を当てているのではなく、負けを小さく切り、勝ちを伸ばすという損益設計で優位性を作っています。

この視点に立つと、「昨日上がったから今日も上がる」という自己相関の話と、トレンドフォローの有効性は、そもそも別の議論だと分かります。

実際に、損益の非対称性がどう効いているかを数字で見てみます。

# 勝率が低くても期待値がプラスになる構造を確認する
scenarios = [
    ("トレンドフォロー", 0.40, 3.0, 1.0),   # 勝率40%、勝ち3・負け1
    ("逆張り",           0.70, 1.0, 2.5),   # 勝率70%、勝ち1・負け2.5
    ("均衡型",           0.55, 1.2, 1.0),
]

print(f"{'戦略':<18s} {'勝率':>6s} {'損益比':>8s} {'期待値':>9s}")
for name, wr, win, loss in scenarios:
    ev = wr * win - (1 - wr) * loss
    print(f"{name:<18s} {wr:>5.0%} {win / loss:>8.2f} {ev:>+9.3f}")

print("\n勝率が低くても、損益比が大きければ期待値はプラスになります")

勝率40%のトレンドフォローでも、勝ちが負けの3倍あれば期待値はプラスです。自己相関がゼロ=方向が読めない世界でも、損益の設計次第で優位性は作れる——これが実務上の結論になります。

逆に言えば、「当てる」ことに労力を注ぐより、「損切りと利確の幅をどう設計するか」に注力したほうが合理的だということです。前者は統計が否定していますが、後者は自分でコントロールできます。

検証するときの注意点

期間で結果が変わる

# 年ごとに1日ラグの自己相関を計算する
by_year = ret.groupby(ret.index.year).apply(
    lambda x: float(x.autocorr(1)) if len(x) > 50 else np.nan
).dropna()

print(by_year.round(4).to_string())
print(f"\n範囲: {by_year.min():.4f} 〜 {by_year.max():.4f}")
print(f"全期間: {float(ret.autocorr(1)):.4f}")

年によって符号が変わることも珍しくありません。特定の1年だけを切り取れば「平均回帰の傾向がある」と主張できてしまうわけです。だからこそ、10年以上の長期で見る必要があります。

終値だけで見ていること

本記事の検証はすべて日次終値ベースです。より短い時間軸では、違う結果が出る可能性があります。実際、分単位のデータでは注文の執行構造に起因する自己相関が観測されることが知られています。

ただし、そこで見つかる微小な偏りを個人が収益化するのは困難です。スプレッドと手数料で消える水準だからです。

「有意」と「儲かる」は別

仮に自己相関が−0.03で統計的に有意だったとしても、そこから取り出せる利益は往復手数料に届きません。検定に通ったからといって実運用できるとは限らない、という点は常に意識しておく必要があります。

# 自己相関の大きさから、期待できる1回あたりの利益を概算する
ac = abs(float(ret.autocorr(1)))
daily_std = float(ret.std())
edge = ac * daily_std * 100          # 概算の期待値(%)
cost = 0.05                          # 往復コスト(%)の例

print(f"自己相関         : {ac:.4f}")
print(f"概算の優位性     : {edge:.4f} % / トレード")
print(f"往復コスト       : {cost:.4f} %")
print(f"差引             : {edge - cost:+.4f} %")
print("→ コスト負けします" if edge < cost else "→ 検討の余地あり")

まとめ

「3連騰したら買い」という経験則を、自己相関とランダムウォーク検定で確かめてきました。要点を整理します。

  • 自己相関は株価ではなくリターンで計算する。株価では当たり前の結果しか出ない
  • 日次リターンの自己相関は±0.05程度。実質的にほぼ無関係
  • 「3連騰の翌日」の勝率は、通常の日と統計的に差がない
  • 分散比検定でも、ランダムウォーク仮説は棄却できないことが多い
  • ただしリターンの絶対値には強い自己相関がある=ボラティリティは持続する
  • つまり方向は読めないが、大きさは読める。統計はリスク管理でこそ活きる
  • トレンドフォローが機能するのは方向予測ではなく損小利大の損益設計による

この検証をやって、僕の中で相場観がかなり整理されました。予測できないものを予測しようとするのをやめて、予測できるもの(リスクの大きさ)に力を注ぐ——方針がはっきりしたという意味で、収穫は大きかったと感じています。

自分が信じている経験則があるなら、ぜひ streak_analysis() にかけてみてください。数字が裏づけてくれることもあれば、きれいに否定されることもあります。どちらの結果でも、感覚のまま持ち続けるよりはるかに有益です。

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