Pythonで株価トレンドを回帰分析|R²が高くても意味はない

株価トレンドを直線で読み解く 基礎知識・戦略

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

株価チャートに直線を引いて「上昇トレンドだ」と判断する。これを数値でやるのが回帰分析です。scikit-learnを使えば5行で書けます。

問題は、その結果をどう読むかです。多くの入門記事は「R²が0.7以上なら強いトレンド」と説明しますが、これは誤りです。株価のようなランダムウォークに近い系列を時間で回帰すると、トレンドが存在しなくてもR²は簡単に0.7を超えます。実際に乱数で確かめると、その頻度に驚きます。

この記事では、回帰の実装から始めて、R²が嘘をつく理由、正しい有意性の判定方法、そして回帰を実際に使える形にする方法までを扱います。

📘 外部参考scikit-learn Linear Models(公式)statsmodels Linear Regression(公式)

基本の実装

まずは素直に書きます。価格そのものではなく対数価格を使うのがポイントです。価格を直接回帰すると「1日あたり+3円」という一定額の上昇を仮定することになりますが、実際の株価は一定率で動きます。対数を取れば、傾きがそのまま日次の期待リターンになります。

import numpy as np
import pandas as pd
import yfinance as yf

def load_close(ticker: str, period: str = "1y") -> pd.Series:
    df = yf.download(ticker, period=period, auto_adjust=True, progress=False)
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)
    return df["Close"].dropna()

def trend(close: pd.Series) -> dict:
    y = np.log(close.to_numpy())
    x = np.arange(len(y), dtype=float)

    slope, intercept = np.polyfit(x, y, 1)
    fitted = intercept + slope * x
    ss_res = float(((y - fitted) ** 2).sum())
    ss_tot = float(((y - y.mean()) ** 2).sum())

    return {
        "slope_daily_pct": round((np.exp(slope) - 1) * 100, 4),
        "annual_pct": round((np.exp(slope * 252) - 1) * 100, 2),
        "r2": round(1 - ss_res / ss_tot, 3),
        "n": len(y),
    }

close = load_close("7203.T", "2y")
print(trend(close))

auto_adjust=Trueは必須です。未調整の価格だと、株式分割の日に価格が飛んで、その1点が傾きを大きく歪めます。

R²は「トレンドの強さ」ではない

ここが本題です。傾きもR²も計算できましたが、これが意味のある数字なのかを確かめる必要があります。

やり方は単純で、トレンドがゼロだと分かっているデータ(ランダムウォーク)で同じ計算をするだけです。トレンドがないのに高いR²が出るなら、R²はトレンドの証拠になりません。

def random_walk_r2(n: int = 500, sigma: float = 0.015,
                   trials: int = 2000, seed: int = 0) -> pd.Series:
    """ドリフトゼロのランダムウォークを回帰したときのR²分布。"""
    rng = np.random.default_rng(seed)
    x = np.arange(n, dtype=float)
    xc = x - x.mean()
    denom = float((xc ** 2).sum())

    out = np.empty(trials)
    for i in range(trials):
        y = np.cumsum(rng.normal(0.0, sigma, n))    # 真のトレンドはゼロ
        yc = y - y.mean()
        slope = float((xc * yc).sum()) / denom
        resid = yc - slope * xc
        out[i] = 1 - float((resid ** 2).sum()) / float((yc ** 2).sum())
    return pd.Series(out)

r2 = random_walk_r2()
print(f"中央値 R² = {r2.median():.3f}")
print(f"R² > 0.5 の割合 = {(r2 > 0.5).mean():.1%}")
print(f"R² > 0.7 の割合 = {(r2 > 0.7).mean():.1%}")
print(f"上位5%点      = {r2.quantile(0.95):.3f}")

実行すると、トレンドがまったく存在しない乱数でも、R²の中央値は0.3〜0.4程度、0.7を超えることも珍しくありません。原因は残差の自己相関です。価格は前日の価格に強く依存しているため、直線から外れた点が長く同じ側に留まります。通常の回帰が前提とする「残差が独立」という条件が成立していません。

これは計量経済学で見せかけの回帰(spurious regression)として知られる現象です。「R²が0.8だから強い上昇トレンド」という判断は、統計的な根拠を持ちません。

よくある解釈実際
R²が高い=トレンドが強いランダムウォークでも高くなる
R²が高い=予測が当たる過去への当てはまりで、将来とは無関係
p値が小さい=有意自己相関で標準誤差が過小評価されている
傾きが正=上昇トレンド符号だけなら5割の確率で正になる

正しく有意性を判定する

方法1:HAC標準誤差を使う

残差の自己相関と不均一分散に頑健な標準誤差(Newey-West、HAC)を使えば、t値が現実的な水準に落ちます。

import statsmodels.api as sm

def trend_hac(close: pd.Series, maxlags: int | None = None) -> dict:
    y = np.log(close.to_numpy())
    x = sm.add_constant(np.arange(len(y), dtype=float))
    if maxlags is None:
        maxlags = int(round(4 * (len(y) / 100) ** (2 / 9)))   # 経験則

    ols = sm.OLS(y, x).fit()
    hac = sm.OLS(y, x).fit(cov_type="HAC", cov_kwds={"maxlags": maxlags})

    return {
        "slope": float(hac.params[1]),
        "t_ols": round(float(ols.tvalues[1]), 2),
        "t_hac": round(float(hac.tvalues[1]), 2),
        "p_hac": round(float(hac.pvalues[1]), 4),
        "r2": round(float(ols.rsquared), 3),
        "dw": round(float(sm.stats.durbin_watson(ols.resid)), 3),
    }

print(trend_hac(close))

t_olst_hacを並べているのが要点です。通常のOLSではt値が20を超えることもありますが、HACでは3以下に落ちることが普通です。この差がそのまま、自己相関を無視したときの過大評価の大きさです。

dw(ダービン・ワトソン統計量)も出しています。2に近ければ自己相関なし、0に近いほど正の自己相関が強い状態です。株価の時間回帰では0.05といった極端な値が出ます。

方法2:日次リターンの平均を見る

そもそも、対数価格を時間で回帰した傾きは、期間全体の平均日次リターンとほぼ同じです。だったら最初からリターンを直接評価するほうが素直で、統計的にも扱いやすくなります。

from scipy import stats

def drift_test(close: pd.Series) -> dict:
    r = np.log(close).diff().dropna()
    t, p = stats.ttest_1samp(r, 0.0)
    se = r.std(ddof=1) / np.sqrt(len(r))
    return {
        "mean_daily_pct": round(r.mean() * 100, 4),
        "annual_pct": round((np.exp(r.mean() * 252) - 1) * 100, 2),
        "t": round(float(t), 2),
        "p": round(float(p), 4),
        "ci95_annual_pct": [
            round((np.exp((r.mean() - 1.96 * se) * 252) - 1) * 100, 1),
            round((np.exp((r.mean() + 1.96 * se) * 252) - 1) * 100, 1),
        ],
    }

print(drift_test(close))

信頼区間を見ると現実が分かります。年率換算で「-15%〜+45%」のような幅になることが多く、1〜2年のデータでトレンドの有無を言い切れないことが数字で示されます。「上昇トレンドです」と断言する前に、この区間がゼロを跨いでいないかを確認してください。

それでも回帰が役に立つ場面

将来予測の道具としては弱いですが、現在位置の記述には使えます。回帰直線からの乖離を標準化すると、いわゆるレグレッションチャネルになります。

def channel(close: pd.Series, window: int = 120, k: float = 2.0) -> pd.DataFrame:
    """直近window日で回帰し、残差のzスコアと予測区間を返す。"""
    y = np.log(close).to_numpy()
    idx = close.index
    n = len(y)
    rows = []

    x = np.arange(window, dtype=float)
    xc = x - x.mean()
    denom = float((xc ** 2).sum())

    for i in range(window, n + 1):
        yw = y[i - window:i]
        yc = yw - yw.mean()
        slope = float((xc * yc).sum()) / denom
        fitted = yw.mean() + slope * (x - x.mean())
        resid = yw - fitted
        sd = resid.std(ddof=2)
        rows.append({
            "date": idx[i - 1],
            "slope_annual_pct": (np.exp(slope * 252) - 1) * 100,
            "z": resid[-1] / sd if sd > 0 else 0.0,
            "upper": np.exp(fitted[-1] + k * sd),
            "lower": np.exp(fitted[-1] - k * sd),
        })
    return pd.DataFrame(rows).set_index("date")

ch = channel(close)
print(ch.tail())

ここで得たzが、「トレンドラインからどれだけ上下に離れているか」の標準化された指標です。ただし、この乖離が縮む保証はありません。使う前に検証します。

def zscore_edge(close: pd.Series, ch: pd.DataFrame,
                horizon: int = 10) -> pd.DataFrame:
    fwd = (close.shift(-horizon) / close - 1).reindex(ch.index)
    tbl = pd.DataFrame({"z": ch["z"], "fwd": fwd}).dropna()
    tbl["bucket"] = pd.cut(tbl["z"], [-np.inf, -2, -1, 1, 2, np.inf],
                           labels=["<-2", "-2〜-1", "-1〜1", "1〜2", ">2"])
    out = tbl.groupby("bucket", observed=True)["fwd"].agg(["count", "mean"])
    out["mean"] = (out["mean"] * 100).round(2)
    out["baseline"] = round(tbl["fwd"].mean() * 100, 2)
    return out

print(zscore_edge(close, ch))

baselineとの比較が肝心です。「z<-2のあと10日で+1.5%」でも、全期間の平均が+1.4%なら、そのシグナルは何も生んでいません。常にベースラインを併記する。これをやらない検証は、ほぼ確実に自分を騙します。

トレンドの傾きは時間とともに変わる

import matplotlib
import matplotlib.pyplot as plt

matplotlib.rcParams["font.family"] = ["Meiryo", "Yu Gothic", "IPAexGothic"]

fig, axes = plt.subplots(2, 1, figsize=(12, 7), sharex=True,
                         gridspec_kw={"height_ratios": [2, 1]})
axes[0].plot(close.index, close, color="steelblue", lw=1, label="終値")
axes[0].plot(ch.index, ch["upper"], color="gray", ls="--", lw=0.8, label="+2σ")
axes[0].plot(ch.index, ch["lower"], color="gray", ls="--", lw=0.8, label="-2σ")
axes[0].legend(); axes[0].grid(alpha=0.3)

axes[1].plot(ch.index, ch["slope_annual_pct"], color="darkorange", lw=1.2)
axes[1].axhline(0, color="black", lw=0.8)
axes[1].set_ylabel("傾き(年率%)"); axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.savefig("trend_regression.png", dpi=150)

下段のグラフを見ると、120日窓の傾きが年率+60%から-40%まで平気で振れることが分かります。「この銘柄は上昇トレンド」という表現が、いかに期間依存の記述かが一目で分かります。窓を60日にすればもっと荒れ、250日にすれば滑らかになります。どれが正しいということはなく、どの時間軸で見ているかを明示するしかありません。

まとめ

回帰分析で株価トレンドを扱うときの要点は次のとおりです。

  • 価格ではなく対数価格を回帰する。傾きが日次リターンになる
  • R²はトレンドの強さを表さない。ランダムウォークでも0.7を超える
  • 有意性はHAC標準誤差で見るか、日次リターンの平均を直接検定する
  • 信頼区間を出す。1〜2年のデータでは区間がゼロを跨ぐことが多い
  • 回帰は予測ではなく現在位置の記述に使う。乖離のzスコアが実用的
  • シグナルの評価には必ずベースラインを併記する

「R²が0.85なので強い上昇トレンドです」と書いてある記事を見たら、その数字が乱数でも出ることを思い出してください。統計の道具は、正しく疑いながら使うと強力です。

📘 外部参考scipy.stats(公式ドキュメント)pandas User Guide(公式)

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