※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。
株価チャートに直線を引いて「上昇トレンドだ」と判断する。これを数値でやるのが回帰分析です。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_olsとt_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なので強い上昇トレンドです」と書いてある記事を見たら、その数字が乱数でも出ることを思い出してください。統計の道具は、正しく疑いながら使うと強力です。

