「このドル円、そろそろ行き過ぎじゃない?」を数字で言いたくてZスコアを勉強した話

基礎知識・戦略

ドル円が162円台に乗るたびに、僕は「さすがにこれは行き過ぎだろう」と口にしていました。でも冷静に振り返ると、その「行き過ぎ」の根拠は完全に気分です。何と比べて行き過ぎなのか、どのくらい行き過ぎなのか——どちらも答えられませんでした。

以前に標準偏差を勉強して、値動きの荒さは数値化できるようになりました。ただ、それは「どれくらい揺れるか」の話であって、「いま現在の価格が普段と比べてどの位置にあるか」はまだ測れていなかったのです。

その穴を埋めてくれたのがZスコアでした。この記事では、ドル円を題材にZスコアをPythonで実装し、僕が実際にハマった落とし穴——特にローリング窓の扱いと、為替特有の「トレンドが長い」という性質への対処——を中心にまとめます。

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

「行き過ぎ」を数字にするとどうなるか

Zスコアの考え方はとても単純で、「平均から標準偏差いくつ分離れているか」を計算するだけです。

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

「162円は高い」ではなく「直近20日の平均から2.4σ上にいる」と言えるようになる。これだけで、判断の土台がまるで変わりました。

何より良かったのは、他人と会話が成立するようになったことです。「高い気がする」は主観ですが、「+2.4σ」は誰が計算しても同じ数字になります。自分の過去の判断を後から検証することもできます。

Zスコアざっくりした感覚どう扱うか
0付近いつもどおり何もしない
±1やや傾いている意識だけしておく
±2けっこう離れている逆張りを検討する水準
±3以上異常何か起きている。追随しない

ちなみに「±2σは統計的に約5%の確率」と説明されることが多いのですが、実際の為替ではもっと頻繁に起こります。この点は後で実データで確認します。

もう1つ、Zスコアを知って良かったと感じているのが通貨ペアをまたいで比較できる点です。ドル円は150円台、ユーロドルは1.08、ポンド円は190円台——単位も桁もバラバラですが、Zスコアに直せば「どれがいちばん普段から離れているか」を並べて判断できます。

import yfinance as yf

PAIRS = {"JPY=X": "ドル円", "EURUSD=X": "ユーロドル",
         "GBPJPY=X": "ポンド円", "AUDJPY=X": "豪ドル円"}

for symbol, name in PAIRS.items():
    s = yf.download(symbol, period="6mo", auto_adjust=True,
                    progress=False)["Close"].dropna()
    mean = s.tail(20).mean()
    z = float((s.iloc[-1] - mean) / s.tail(20).std())
    print(f"{name:<8s} 現在 {float(s.iloc[-1]):>9.4f}  Zスコア {z:>6.2f}")

「なんとなくポンド円が動いている気がする」ではなく、「ポンド円だけ+2.4σで突出している」と分かる。監視対象が増えたときに、どこを見るべきかを絞り込めるようになりました。

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

最初にやらかした失敗:全期間の平均を使う

最初に書いたコードがこれです。今見ると恥ずかしいのですが、当時はこれで満足していました。

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

px = yf.download("JPY=X", period="2y", auto_adjust=True,
                 progress=False)["Close"].dropna()

# ✕ これがダメだった
z_bad = (px - px.mean()) / px.std()
print(f"現在のZスコア: {float(z_bad.iloc[-1]):.2f}")

問題は2つあります。

1つめは未来の情報を使っていることです。px.mean() は2年分すべての平均なので、1年前の時点のZスコアを計算するときに「その後1年間の値動き」を混ぜ込んでいます。当時の自分には知りようがない情報です。

2つめはトレンドがあると使い物にならないことです。ドル円のように何年もかけて一方向に動く相場では、全期間の平均は常に現在より下(または上)にあります。結果として、Zスコアはずっと同じ符号に張り付きます。

# 全期間平均でZスコアを出すと、符号がどれだけ偏るか
z_bad = ((px - px.mean()) / px.std()).dropna()
print(f"プラス圏の割合: {float((z_bad > 0).mean()) * 100:>5.1f} %")
print(f"±2σ超えの日数: {int((z_bad.abs() > 2).sum())} 日")
print(f"最初の30日の平均Z: {float(z_bad.head(30).mean()):>6.2f}")
print(f"最後の30日の平均Z: {float(z_bad.tail(30).mean()):>6.2f}")

実行すると、期間の前半はずっとマイナス、後半はずっとプラスという結果になります。これでは「行き過ぎ」の判定にはまったく使えません。ただ「昔より高いか安いか」を言っているだけです。

ローリング窓で計算し直す

解決策は、直近N日だけを基準にすることです。「この1か月の中でどのあたりか」を測れば、長期トレンドの影響を受けません。

def rolling_zscore(series, window=20, shift=True):
    """直近window日を基準にしたZスコア

    shift=True にすると当日を含めず、前日までのデータで計算する
    (実運用でそのまま使える形)
    """
    base = series.shift(1) if shift else series
    mean = base.rolling(window).mean()
    std = base.rolling(window).std()
    return (series - mean) / std


z = rolling_zscore(px, window=20).dropna()

print(f"現在のZスコア : {float(z.iloc[-1]):>6.2f}")
print(f"プラス圏の割合: {float((z > 0).mean()) * 100:>5.1f} %  ← 50%前後が健全")
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σ超えも適度な頻度で現れます。これでようやく「行き過ぎ」を測る道具になりました。

shift=True を既定にしているのが個人的なこだわりです。当日の終値を含めた平均と当日の終値を比べるのは、厳密には少し反則です。前日までのデータで基準を作り、そこに当日を当てはめるほうが実運用に近くなります。

この手の「知らないはずの情報を使ってしまう」問題は、バックテストの成績を不当に良く見せる最大の原因です。詳しくはルックアヘッドバイアスをPythonで排除する方法にまとめてあります。

窓の長さをどう決めるか

ここも悩みました。結論としては「自分が想定する保有期間に合わせる」のが正解でした。

for w in (5, 10, 20, 60, 120):
    zz = rolling_zscore(px, window=w).dropna()
    hits = int((zz.abs() > 2).sum())
    print(f"窓 {w:>3d}日 → 現在 {float(zz.iloc[-1]):>6.2f} / "
          f"±2σ超え {hits:>3d}回 ({hits / len(zz) * 100:>4.1f}%) / "
          f"標準偏差 {float(zz.std()):.2f}")
性格向いている使い方
5〜10日敏感。シグナル多発デイトレ。ノイズも多い
20日バランス型数日〜2週間の保有
60日鈍い。シグナルは希少1か月以上の保有
120日ほぼトレンド判定逆張り用途には不向き

僕は20日に落ち着きました。5日だと1日の値動きだけでZスコアが±2に飛ぶことがあり、シグナルとして信用できなかったためです。

為替では「±2σは珍しくない」

教科書には「±2σを超えるのは約5%」と書いてありますが、実際のドル円で数えるともっと多く出ます。理由はリターン分布の裾が正規分布より厚いからです。

from scipy.stats import norm

z = rolling_zscore(px, window=20).dropna()
n = len(z)

print(f"{'閾値':>6s} {'実際':>8s} {'理論値':>10s} {'倍率':>8s}")
for k in (1, 2, 2.5, 3):
    actual = int((z.abs() > k).sum())
    expected = 2 * norm.sf(k) * n
    ratio = actual / expected if expected else float("inf")
    print(f"±{k:>4.1f}σ {actual:>8d} {expected:>10.1f} {ratio:>7.1f}倍")

±3σあたりから、実際の発生回数が理論値を大きく上回ることが確認できます。「3σだからほぼ起きない」と考えて建てたポジションが、あっさり吹き飛ぶ——為替でこれをやると痛い目に遭います。

だからこそ、Zスコアを使うときは「めったに起きない=起きない」ではないと肝に銘じておく必要があります。逆張りするなら、想定を超える方向に動いたときの損切りを必ずセットで用意してください。

僕が採用しているのは、次のような二段構えのルールです。

  • ±2.0でエントリー: 平均回帰を期待して逆張りする
  • ±3.5で損切り: ここまで来たら「行き過ぎ」ではなく「水準が変わった」と判断する
  • ±0.5で手仕舞い: 戻ったら欲張らずに降りる
  • 10営業日で強制手仕舞い: 戻らないまま持ち続けない

特に最後の時間制限が効きました。「いつか戻るはず」で持ち続けるのがいちばん危ないというのは、身をもって学んだことです。平均回帰を前提にした戦略は、前提が崩れたときに損失が無限に伸びる構造を持っています。

逆張りシグナルを作って検証する

実際に売買ルールに落とし込んでみます。「−2σで買い、0付近で手仕舞い」というシンプルな平均回帰戦略です。

ENTRY, EXIT = 2.0, 0.5

df = pd.DataFrame({"close": px})
df["z"] = rolling_zscore(px, window=20)
df["ret"] = df["close"].pct_change()
df = df.dropna()

# シグナル生成(-2σで買い、+2σで売り、±0.5で手仕舞い)
sig = pd.Series(np.nan, index=df.index)
sig[df["z"] < -ENTRY] = 1
sig[df["z"] > ENTRY] = -1
sig[df["z"].abs() < EXIT] = 0

# 前日のシグナルで当日建てる(ここを忘れると成績が嘘になる)
df["position"] = sig.ffill().fillna(0).shift(1)
df["strategy"] = df["position"] * df["ret"]

cum_bh = float((1 + df["ret"]).prod()) - 1
cum_st = float((1 + df["strategy"]).prod()) - 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} %")
print(f"買い持ち     : {cum_bh * 100:>7.2f} %")
print(f"Zスコア戦略  : {cum_st * 100:>7.2f} %")

この結果をどう受け止めるかですが、スプレッドもスワップも入れていないので、そのまま鵜呑みにはできません。ドル円のスプレッドは狭いとはいえ、売買回数が増えれば確実に効いてきます。コストの入れ方はバックテストにスリッページを加味する実装方法を参考にしてください。

トレンド相場では逆張りが効かない

検証していて痛感したのがこれです。Zスコアの逆張りは「平均に戻る」ことが前提なので、一方向に走り続ける相場では負け続けます。

# 相場の状態別に、逆張り戦略の成績を分けて見る
df["ma"] = df["close"].rolling(60).mean()
df["trend"] = np.where(df["close"] > df["ma"] * 1.02, "上昇",
                np.where(df["close"] < df["ma"] * 0.98, "下降", "レンジ"))

summary = df.groupby("trend")["strategy"].agg(
    日数="count",
    平均損益=lambda s: float(s.mean()) * 100,
    合計損益=lambda s: float((1 + s).prod() - 1) * 100,
    勝率=lambda s: float((s[s != 0] > 0).mean()) * 100 if (s != 0).any() else 0,
)
print(summary.round(3).to_string())

実行すると、レンジ相場では機能し、トレンド相場では削られるという傾向がはっきり出ます。当たり前といえば当たり前ですが、数字で見ると納得感が違いました。

ここから学んだのは、Zスコアは単体で使う指標ではないということです。「いまがレンジ相場かどうか」を判定するフィルターを前段に置いて、レンジのときだけ逆張りする。この組み合わせにして、ようやく実用的になりました。

レンジ判定フィルターを足す

フィルターにはADX(平均方向性指数)を使いました。トレンドの強さを数値化する指標で、値が小さいほどレンジ相場を意味します。

def adx(high, low, close, period=14):
    """トレンドの強さを測る(値が小さいほどレンジ)"""
    up = high.diff()
    down = -low.diff()
    plus_dm = np.where((up > down) & (up > 0), up, 0.0)
    minus_dm = np.where((down > up) & (down > 0), down, 0.0)

    tr = pd.concat([high - low,
                    (high - close.shift()).abs(),
                    (low - close.shift()).abs()], axis=1).max(axis=1)
    atr = tr.ewm(alpha=1 / period, adjust=False).mean()

    plus_di = 100 * pd.Series(plus_dm, index=high.index).ewm(
        alpha=1 / period, adjust=False).mean() / atr
    minus_di = 100 * pd.Series(minus_dm, index=high.index).ewm(
        alpha=1 / period, adjust=False).mean() / atr

    dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di)
    return dx.ewm(alpha=1 / period, adjust=False).mean()


ohlc = yf.download("JPY=X", period="2y", auto_adjust=True, progress=False)
ohlc["adx"] = adx(ohlc["High"], ohlc["Low"], ohlc["Close"])
ohlc["z"] = rolling_zscore(ohlc["Close"], window=20)

latest = ohlc.dropna().iloc[-1]
print(f"ADX     : {float(latest['adx']):>6.1f}")
print(f"Zスコア : {float(latest['z']):>6.2f}")

if float(latest["adx"]) < 25 and abs(float(latest["z"])) > 2:
    print("→ レンジ相場で行き過ぎ。逆張りが機能しやすい局面です")
elif float(latest["adx"]) >= 25:
    print("→ トレンド発生中。Zスコアの逆張りは見送ります")

目安はADXが25未満ならレンジです。この条件を加えるだけで、トレンド相場で無駄に逆張りして削られる場面が大きく減りました。

相場の状態によって戦略を切り替えるという発想は、市場レジーム検出で戦略を自動切り替えする方法でさらに詳しく扱っています。

使ってみて分かった注意点

経済指標の発表前後は当てにならない

雇用統計やFOMCの直後は、Zスコアが一瞬で±3を超えることがあります。しかしこれは「行き過ぎ」ではなく「新しい水準への移行」であることが多く、逆張りすると轢かれます。

重要指標の前後はシグナルを無効にするのが安全です。カレンダーを見て手動で止めるだけでも効果があります。

介入という「反則技」がある

為替特有のリスクです。通貨当局の介入が入ると、統計的な分析がまったく通用しない値動きが発生します。数分で数円動くこともあり、逆方向のポジションを持っていれば損切りが間に合いません。

これはZスコアの問題ではなく、為替という市場の性質です。レバレッジを抑える以外に有効な対策はないと考えています。

スワップポイントを忘れない

ドル円を売り持ちすると、日々スワップポイントを支払うことになります。「−2σだから買い」は順張りのスワップが受け取れますが、「+2σだから売り」は保有するほどコストがかかります。

買いと売りで条件が非対称だという点は、株式のバックテストにはない為替特有の要素です。検証するなら必ず織り込んでください。

週末を挟むと標準偏差が歪む

細かい話ですが、月曜日は金曜引けからの2日分の値動きが乗るため、他の曜日より大きく動きがちです。日次データでZスコアを計算していると、月曜だけ極端な値が出やすくなります

# 曜日ごとの値動きの大きさを確認する
ret = px.pct_change().dropna()
by_dow = ret.abs().groupby(ret.index.dayofweek).mean() * 100
names = ["月", "火", "水", "木", "金"]
for i, v in by_dow.items():
    if i < 5:
        print(f"{names[i]}曜  平均変動率 {float(v):.3f} %")

差が大きいようなら、月曜のシグナルは無視するか、曜日ごとに基準を分けるという対処もあります。僕は単純に「月曜はエントリーしない」というルールにしています。

まとめ

「行き過ぎ」を数字にするためにZスコアを勉強して、実際に使えるところまで持っていった記録でした。要点を整理します。

  • Zスコアは(いまの値 − 平均)÷ 標準偏差。感覚を共通言語に変えてくれる
  • 全期間の平均を使うと失敗する。未来の情報が混ざり、トレンドで符号が偏る
  • ローリング窓+前日基準(shift(1))にすれば、実運用でそのまま使える
  • 窓は保有期間に合わせる。数日〜2週間なら20日が扱いやすい
  • 為替では±3σが理論値よりずっと頻繁に起きる。損切りは必須
  • 逆張りが効くのはレンジ相場だけ。ADX25未満のフィルターを前段に置く
  • 指標発表・介入・スワップ——為替特有の要素は統計では扱えない

いちばん変わったのは、「行き過ぎ」と口にする回数が減ったことかもしれません。計算してみると、自分が行き過ぎだと感じていた場面の多くはZスコア1.2程度で、統計的にはごく普通の値動きでした。感覚がいかに当てにならないかを、数字で突きつけられた形です。

まずは rolling_zscore() をコピーして、いま見ている通貨ペアで走らせてみてください。「思ったより普通だった」という結果が出たなら、それだけで無駄なエントリーを1回減らせます。

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