PythonでZスコアを計算して株価の割高・割安を判断する方法

いまの株価は割高か割安か 基礎知識・戦略

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

「この株、さすがに上がりすぎでは」と感じたとき、その感覚を数字にできるでしょうか。株価3,200円という数字そのものには、割高も割安もありません。その銘柄にとって普段どのあたりで推移しているのかが分かって初めて、いまの位置が評価できます。

これを1つの数値で表すのがZスコアです。「平均から標準偏差いくつ分離れているか」を計算するだけのシンプルな指標ですが、株価の水準が違う銘柄同士を同じ物差しで比較できるという強力な性質を持っています。

この記事では、PythonでZスコアを計算して日本株の割高・割安を判定する方法を解説します。後半では、Zスコアの最も実践的な応用であるペア取引(スタティスティカル・アービトラージ)まで踏み込み、2銘柄の価格差が開いたときに仕掛ける手法を実装します。

📘 外部参考Z-Score(Investopedia)標準得点(Wikipedia)

Zスコアは「いつもと比べてどうか」を測る

Zスコアの計算式は次のとおりです。

Zスコア = (現在の値 − 平均) ÷ 標準偏差

やっていることは、平均からの距離を標準偏差という単位で測り直す作業です。この変換により、単位も水準もバラバラな数字が「σ何個分」という共通の物差しに載ります。

Zスコア意味正規分布での位置投資での解釈
0平均どおりちょうど真ん中特に何もない
±11σ分ずれている上位・下位16%やや傾いている程度
±22σ分ずれている上位・下位2.3%逆張りの候補
±33σ分ずれている上位・下位0.13%異常事態。何か起きている

実務では±2を目安にすることが多いです。統計的には「めったに起きない」水準でありながら、実際には数十営業日に一度は現れるため、シグナルとして使える頻度があります。

株価そのものにZスコアを使ってはいけない

最初に押さえておくべき注意点があります。株価は長期的に上昇トレンドを持つため、価格そのもののZスコアは意味を持ちにくいのです。

右肩上がりの銘柄では、過去の平均は常に現在より下にあります。その結果、Zスコアはずっとプラス圏に張り付き、「万年割高」という判定になってしまいます。

この問題を避ける方法は3つあります。

  • ローリング窓で計算する: 直近20〜60日など、限られた期間の平均と比べる
  • 移動平均からの乖離率に対して使う: トレンド成分を取り除いてから測る
  • 2銘柄の価格差(スプレッド)に使う: トレンドが相殺される。これがペア取引

本記事では1と3を扱います。特に3つめが、Zスコアが最も力を発揮する使い方です。

ローリングZスコアを実装する

pip install yfinance pandas numpy matplotlib statsmodels

直近N日の平均と標準偏差を使って、転がしながら計算します。

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


def rolling_zscore(series, window=20):
    """直近window日を基準にしたZスコア"""
    mean = series.rolling(window).mean()
    std = series.rolling(window).std()
    return (series - mean) / std


px = yf.download("7203.T", period="2y", auto_adjust=True,
                 progress=False)["Close"]
z = rolling_zscore(px, window=20).dropna()

print(f"現在のZスコア : {float(z.iloc[-1]):>6.2f}")
print(f"過去2年の範囲 : {float(z.min()):>6.2f} 〜 {float(z.max()):.2f}")
print(f"+2σ超えの日数 : {int((z > 2).sum()):>4d} 日 "
      f"({float((z > 2).mean()) * 100:.1f}%)")
print(f"-2σ割れの日数 : {int((z < -2).sum()):>4d} 日 "
      f"({float((z < -2).mean()) * 100:.1f}%)")

ここで正規分布の理論値(±2σ超えは約4.6%)より実際の頻度が高いことに気づくはずです。株価のリターン分布は裾が厚いため、極端な値が理論より多く出ます。この点は正規分布と株価リターンの現実を確認する方法で詳しく検証しています。

未来の情報を使わないための注意

ここが最も間違えやすい箇所です。全期間の平均と標準偏差を使ってZスコアを計算すると、その時点では知りえないはずの未来のデータを使ってしまいます

# ✕ 悪い例: 全期間の統計量を使う(未来の情報が混入する)
bad_z = (px - px.mean()) / px.std()

# ○ 良い例: その時点までのデータだけを使う
good_z = rolling_zscore(px, window=20)

# さらに厳密にするなら、当日を含めず前日までで計算する
strict_z = (px - px.shift(1).rolling(20).mean()) / px.shift(1).rolling(20).std()

comp = pd.DataFrame({"全期間": bad_z, "ローリング": good_z,
                     "前日まで": strict_z}).dropna()
print(comp.tail(5).round(2).to_string())

バックテストで異常に良い成績が出たときは、たいていこのルックアヘッドバイアスが原因です。「全期間の平均」は研究段階では便利ですが、実運用のシグナルには絶対に使えません。詳しくはルックアヘッドバイアスをPythonで排除する方法をご覧ください。

窓の長さで性格が変わる

for w in (10, 20, 60, 120):
    z = rolling_zscore(px, window=w).dropna()
    signals = int(((z > 2) | (z < -2)).sum())
    print(f"窓 {w:>3d}日 → 現在 {float(z.iloc[-1]):>6.2f} / "
          f"±2σ超え {signals:>4d}回 ({signals / len(z) * 100:>4.1f}%)")

窓が短いほどシグナルは頻繁に出ますが、その分ノイズも増えます。長くすると精度は上がるものの、機会は減ります。短期売買なら10〜20日、スイングなら60日あたりが実用的な目安です。

移動平均乖離率にZスコアを重ねる

トレンドの影響をより確実に取り除きたい場合は、価格ではなく移動平均からの乖離率にZスコアを適用します。

def deviation_zscore(series, ma_window=25, z_window=60):
    """移動平均乖離率のZスコア(トレンド除去版)"""
    ma = series.rolling(ma_window).mean()
    deviation = (series - ma) / ma * 100          # 乖離率(%)
    z = (deviation - deviation.rolling(z_window).mean()) \
        / deviation.rolling(z_window).std()
    return deviation, z


dev, z = deviation_zscore(px)
df = pd.DataFrame({"終値": px, "乖離率%": dev, "Zスコア": z}).dropna()
print(df.tail(8).round(2).to_string())

latest = df.iloc[-1]
if latest["Zスコア"] > 2:
    print("\n→ 普段より大きく上に離れています(過熱ぎみ)")
elif latest["Zスコア"] < -2:
    print("\n→ 普段より大きく下に離れています(売られすぎ)")
else:
    print("\n→ 通常の範囲内です")

この方法の利点は、「乖離率が大きい」ではなく「その銘柄にしては乖離率が大きい」を判定できる点です。普段から移動平均を10%離れて動く銘柄と、2%しか離れない銘柄では、同じ5%の乖離でも意味がまったく違います。Zスコアはその差を吸収してくれます。

乖離率そのものの計算については移動平均乖離率を計算・表示する方法で解説しています。

複数銘柄を横並びでスクリーニングする

Zスコアの真価は銘柄間の比較にあります。株価3,200円と18,000円の銘柄を、同じ基準で「どちらがより売られすぎか」判定できます。

WATCHLIST = {
    "7203.T": "トヨタ自動車", "6758.T": "ソニーG", "6501.T": "日立製作所",
    "9432.T": "NTT", "8306.T": "三菱UFJ", "6367.T": "ダイキン",
    "8035.T": "東エレク", "4063.T": "信越化学", "6902.T": "デンソー",
}

rows = []
for code, name in WATCHLIST.items():
    try:
        s = yf.download(code, period="1y", auto_adjust=True,
                        progress=False)["Close"].dropna()
        z = rolling_zscore(s, window=20).dropna()
        rows.append({
            "銘柄": name,
            "終値": round(float(s.iloc[-1]), 1),
            "Zスコア": round(float(z.iloc[-1]), 2),
            "20日平均": round(float(s.rolling(20).mean().iloc[-1]), 1),
        })
    except Exception:
        continue

df = pd.DataFrame(rows).sort_values("Zスコア")
df["判定"] = pd.cut(df["Zスコア"],
                    bins=[-np.inf, -2, -1, 1, 2, np.inf],
                    labels=["売られすぎ", "やや安い", "普通",
                            "やや高い", "買われすぎ"])
print(df.to_string(index=False))

これでウォッチリスト全体の「今の位置」が一覧できます。毎朝これを回して、−2を下回った銘柄だけをチェックする、といった運用ができます。自動実行の仕組みはタスクスケジューラで自動実行する完全手順にまとめています。

ペア取引:Zスコアの本命の使い方

ここからが本題です。単一銘柄のZスコアには「下がっているものはさらに下がるかもしれない」という根本的な弱点があります。売られすぎに見えても、業績悪化が原因なら戻ってきません。

そこで登場するのがペア取引です。似た値動きをする2銘柄の価格差に注目し、その差が普段より開いたときに「縮む方向」に賭ける手法です。

スプレッドのZスコア状況取るポジション
+2以上A社がB社に対して割高A社を売り/B社を買い
−2以下A社がB社に対して割安A社を買い/B社を売り
0付近差が解消した手仕舞い

この手法の優れた点は、市場全体の上下に影響されにくいことです。両建てなので、日経平均が暴落しても両方が同じように下がれば損益はほぼ変わりません。賭けているのは「2社の相対関係」だけです。

まず共和分を確認する

ペアを選ぶとき、相関係数だけで判断するのは危険です。必要なのは相関ではなく共和分(コインテグレーション)——「2つの価格差が一定の範囲に戻る性質があるか」です。

from statsmodels.tsa.stattools import coint
import itertools

CANDIDATES = ["7203.T", "7267.T", "7201.T",   # 自動車
              "8306.T", "8316.T", "8411.T"]   # 銀行

prices = yf.download(CANDIDATES, period="3y", auto_adjust=True,
                     progress=False)["Close"].dropna()

print(f"{'ペア':<22s} {'相関':>7s} {'p値':>8s}  判定")
for a, b in itertools.combinations(CANDIDATES, 2):
    corr = float(prices[a].corr(prices[b]))
    _, pvalue, _ = coint(prices[a], prices[b])
    verdict = "○ 共和分あり" if pvalue < 0.05 else "×"
    print(f"{a} / {b:<12s} {corr:>7.3f} {pvalue:>8.4f}  {verdict}")

実行すると、相関は0.9と高いのに共和分検定は不合格というペアが出てきます。これは「同じ方向に動くが、差はどんどん開いていく」状態を意味し、ペア取引には使えません。

相関と共和分の違いは、「一緒に動くか」と「離れても戻ってくるか」の違いです。ペア取引で必要なのは後者だけです。p値が0.05未満のペアだけを候補にしてください。

ヘッジ比率を求めてスプレッドを作る

2銘柄は株価水準が違うので、単純な引き算では差が正しく測れません。回帰分析でヘッジ比率を求め、金額を釣り合わせます。

import statsmodels.api as sm

A, B = "7203.T", "7267.T"     # トヨタ と ホンダ
pair = prices[[A, B]].dropna()

# B = alpha + beta * A の beta(ヘッジ比率)を求める
model = sm.OLS(pair[B], sm.add_constant(pair[A])).fit()
beta = float(model.params.iloc[1])

spread = pair[B] - beta * pair[A]
z = (spread - spread.rolling(60).mean()) / spread.rolling(60).std()

print(f"ヘッジ比率 beta : {beta:.4f}")
print(f"→ {A} を100株買うなら {B} を約 {beta * 100:.0f} 株売る")
print(f"現在のZスコア   : {float(z.dropna().iloc[-1]):.2f}")

ヘッジ比率を掛けることで、両者の値動きの大きさが揃います。これを省くと、単に株価の高いほうの動きに支配されるだけのポジションになってしまいます。

シグナルを生成する

ENTRY, EXIT = 2.0, 0.5

df = pd.DataFrame({"spread": spread, "z": z}).dropna()
df["signal"] = 0
df.loc[df["z"] > ENTRY, "signal"] = -1    # スプレッド縮小に賭ける
df.loc[df["z"] < -ENTRY, "signal"] = 1    # スプレッド拡大に賭ける
df.loc[df["z"].abs() < EXIT, "signal"] = 0  # 手仕舞い

# シグナルは前日のZスコアで判断し、翌日に執行する
df["position"] = df["signal"].replace(0, np.nan).ffill().fillna(0).shift(1)

trades = int((df["position"].diff().abs() > 0).sum())
print(f"検証期間      : {df.index[0].date()} 〜 {df.index[-1].date()}")
print(f"売買回数      : {trades} 回")
print(f"ポジション保有: {float((df['position'] != 0).mean()) * 100:.1f} % の期間")

.shift(1) を必ず入れてください。その日のZスコアを見てその日の終値で約定するのは不可能な取引です。ここを忘れると、成績が非現実的に良く出ます。

また、エントリーを±2、手仕舞いを±0.5と非対称にしているのは頻繁な出入りを避けるためです。0ちょうどで手仕舞う設定にすると、Zスコアが0付近を行き来するたびに売買が発生し、手数料負けします。

実運用で気をつけること

共和分は永遠には続かない

最大のリスクがこれです。過去3年間きれいに連動していた2社も、片方が経営統合や事業売却をすれば関係は壊れます。そうなるとスプレッドは戻らず、Zスコアは3、4、5と広がり続けます。

対策は次の3つです。

  • Zスコアに損切りラインを置く: ±3.5を超えたら前提が崩れたとみなして撤退
  • 定期的に共和分検定をやり直す: 四半期ごとにp値を再確認する
  • 保有期間の上限を決める: 30営業日経っても戻らなければ手仕舞う

「平均に戻る」という前提に賭ける戦略は、前提が崩れたときの損失が青天井になりがちです。損切りラインは必ず設定してください。

空売りのコストと制約

ペア取引は片側で空売りが必要です。日本株では次の点に注意が必要です。

  • 貸株料が日々かかる。保有期間が長引くほど収益を削る
  • 逆日歩が発生すると、想定外のコストになることがある
  • 銘柄によってはそもそも空売りできない(貸借銘柄でない場合)
  • 信用取引口座の開設と、両建て分の資金・保証金が必要

バックテストではこれらのコストを必ず織り込んでください。理論上のリターンが年10%でも、コストで半分以上が消えることは珍しくありません。

Zスコアが機能しない局面

Zスコアは「平均に戻る」ことを前提にした指標です。したがって、強いトレンドが発生している局面では逆効果になります。

# トレンドの強さを測って、逆張りを避ける局面を判定する
def trend_strength(series, window=25):
    """終値が移動平均より上にあった日の割合"""
    ma = series.rolling(window).mean()
    return float((series > ma).tail(window).mean())


strength = trend_strength(px)
z_now = float(rolling_zscore(px, 20).dropna().iloc[-1])

print(f"トレンド強度: {strength:.0%}  Zスコア: {z_now:.2f}")
if strength > 0.8 and z_now > 2:
    print("→ 強い上昇トレンド中。逆張りは危険です")
elif 0.3 < strength < 0.7:
    print("→ レンジ相場。Zスコアの逆張りが機能しやすい局面です")

「Zスコアが+2だから売り」と機械的に判断せず、そもそも逆張りが有効な相場かどうかを先に確認する。この一手間が損失を大きく減らします。

まとめ

Zスコアによる割高・割安の判定と、ペア取引への応用を見てきました。要点を整理します。

  • Zスコアは(値 − 平均)÷ 標準偏差。株価水準が違う銘柄を同じ物差しで比較できる
  • 株価そのものに使うと万年割高になる。ローリング窓か乖離率に対して使う
  • 全期間の平均を使うとルックアヘッドバイアス。必ずその時点までのデータで計算する
  • ペア取引では相関ではなく共和分を確認する。p値0.05未満が条件
  • ヘッジ比率を掛けて金額を揃えないと、片側の値動きに支配される
  • エントリー±2・手仕舞い±0.5のように非対称にして、無駄な売買を避ける
  • 共和分は壊れる。±3.5で損切りと保有期間上限を必ず設定する

Zスコアは計算式こそ単純ですが、「何に対して適用するか」で使い勝手がまるで変わります。単一銘柄への適用は入口にすぎず、スプレッドに適用して初めて統計的な優位性を狙える——ここが実務での分かれ目です。

まずは自分のウォッチリストでローリングZスコアを計算し、一覧を眺めてみてください。「なんとなく高い気がする」が「+2.3σ」という数字に変わるだけで、判断の質は確実に上がります。

そのうえでペア取引に進むなら、いきなり資金を入れずまずは共和分検定を通ったペアを数組見つけて、Zスコアの推移を数か月眺めるところから始めるのがおすすめです。「教科書どおりに平均へ戻る回」と「戻らずに広がり続ける回」の両方を目にしてから実弾を入れると、損切りラインの重要性が身に染みて理解できます。

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