PythonでRSI×MACDの逆張り戦略をバックテストする方法【コピペで動くサンプル付き】

Python実装・コード

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

逆張りの定番といえばRSIです。「30を割ったら売られすぎだから買い」——シンプルで分かりやすいのですが、これを単独で使うと下落トレンドの途中で買い続けて資金を溶かすという典型的な失敗に陥ります。

RSIは「売られすぎ」を教えてくれますが、「それがいつ終わるか」は何も教えてくれません。落ちているナイフを掴む道具になってしまうわけです。

そこで組み合わせるのがMACDです。この記事では戦略ロジックの設計そのものに焦点を当て、2つの指標をどう噛み合わせれば意味のあるシグナルになるのかを、Pythonのコードと検証結果で確認していきます。ライブラリに依存しない素のコードなので、どの環境にも移植できます。

なぜRSI単独では機能しないのか

まず、問題を数字で確認します。RSIだけで逆張りするとどうなるかを実際に検証してみましょう。

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


def rsi(close, period=14):
    """RSI(相対力指数)を計算する"""
    delta = close.diff()
    gain = delta.clip(lower=0)
    loss = -delta.clip(upper=0)

    # 指数平滑(Wilderの平滑化)
    avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean()
    avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean()

    rs = avg_gain / avg_loss.replace(0, np.nan)
    return 100 - 100 / (1 + rs)


px = yf.download("6501.T", period="5y", auto_adjust=True,
                 progress=False)["Close"].dropna()

df = pd.DataFrame({"close": px})
df["ret"] = df["close"].pct_change()
df["rsi"] = rsi(df["close"])

# RSI単独: 30を割ったら買い、70を超えたら手仕舞い
sig = pd.Series(np.nan, index=df.index)
sig[df["rsi"] < 30] = 1
sig[df["rsi"] > 70] = 0
df["pos_rsi"] = sig.ffill().fillna(0).shift(1)
df["pnl_rsi"] = df["pos_rsi"] * df["ret"]

d = df.dropna()
eq = (1 + d["pnl_rsi"]).cumprod()
print(f"RSI単独 累積リターン : {float(eq.iloc[-1] - 1) * 100:>7.1f} %")
print(f"最大ドローダウン     : "
      f"{float((eq / eq.cummax() - 1).min()) * 100:>7.1f} %")
print(f"保有していた割合     : "
      f"{float((d['pos_rsi'] != 0).mean()) * 100:>7.1f} %")

RSI単独の問題点は、ドローダウンの深さに表れます。下落トレンドが始まると、RSIは30を割ったまま何週間も低空飛行を続けます。その間ずっと買い持ちなので、含み損が膨らみ続けるわけです。

# RSIが30未満だった連続日数を数える
below = (d["rsi"] < 30).astype(int)
groups = (below != below.shift()).cumsum()
streaks = below.groupby(groups).sum()
streaks = streaks[streaks > 0]

print(f"RSI30割れの局面数   : {len(streaks)} 回")
print(f"平均の継続日数      : {float(streaks.mean()):.1f} 日")
print(f"最長の継続日数      : {int(streaks.max())} 日")
print("\n→ 一度売られすぎになると、そのまま数週間続くことがあります")

「売られすぎ」は底の合図ではなく、下落が強いことの証拠でもあります。ここを取り違えると、下がり続ける銘柄をひたすら買い増すことになります。

📘 外部参考Relative Strength Index(Investopedia)MACD(Investopedia)

MACDが補ってくれるもの

ここでMACDの出番です。この2つは測っているものが根本的に違います

RSIMACD
測るもの値動きの強さトレンドの向き
答える問い行き過ぎているか流れが変わったか
性格逆張り(オシレーター)順張り(トレンド系)
弱点下落中に鳴り続ける反応が遅い

組み合わせの狙いは明確です。RSIで「安いところ」を探し、MACDで「下落が止まったか」を確認する。片方の弱点をもう片方が補う関係になっています。

def macd(close, fast=12, slow=26, signal=9):
    """MACD・シグナル線・ヒストグラムを返す"""
    ema_fast = close.ewm(span=fast, adjust=False).mean()
    ema_slow = close.ewm(span=slow, adjust=False).mean()

    macd_line = ema_fast - ema_slow
    signal_line = macd_line.ewm(span=signal, adjust=False).mean()
    histogram = macd_line - signal_line

    return macd_line, signal_line, histogram


df["macd"], df["signal"], df["hist"] = macd(df["close"])
print(df[["close", "rsi", "macd", "signal", "hist"]].tail(5).round(2).to_string())

MACDで注目するのはヒストグラム(MACD線とシグナル線の差)です。この値がマイナスからプラスに転じる瞬間が、下降の勢いが弱まって上向きに転じたサインになります。

戦略ロジックを設計する

組み合わせ方には、大きく2つの発想があります。この選択が成績を大きく左右します。

方式条件特徴
AND条件両方が同時に成立回数は減るが精度は上がる
OR条件どちらかが成立回数は増えるがダマシも増える
順序条件RSIが先、MACDが後最も理にかなっている

採用すべきは順序条件です。理由は、この戦略が捉えようとしている現象の順番と一致するからです。

  • まず売られすぎの状態になる(RSIが30を割る)
  • その後下落の勢いが止まる(MACDヒストグラムが好転する)
  • そこでエントリーする

AND条件だと「RSIが30未満で、かつMACDが好転した日」しか拾えません。しかし実際には、RSIが30を割った数日後にMACDが好転するという順序で動きます。その頃にはRSIが35くらいまで戻っていることも多く、AND条件では取り逃がします。

def build_signal(df, rsi_entry=30, rsi_exit=70, lookback=10):
    """RSIで候補を絞り、MACDの好転でエントリーする

    lookback : RSIが売られすぎになってから何日以内を有効とするか
    """
    oversold = df["rsi"] < rsi_entry
    # 直近lookback日以内に売られすぎがあったか
    recently_oversold = oversold.rolling(lookback).max().astype(bool)

    # MACDヒストグラムがマイナスからプラスへ転換
    macd_turn = (df["hist"] > 0) & (df["hist"].shift(1) <= 0)

    entry = recently_oversold & macd_turn

    # 手仕舞い: RSIが買われすぎ、またはMACDが下向きに転換
    exit_signal = (df["rsi"] > rsi_exit) | \
                  ((df["hist"] < 0) & (df["hist"].shift(1) >= 0))

    sig = pd.Series(np.nan, index=df.index)
    sig[entry] = 1
    sig[exit_signal] = 0
    return sig.ffill().fillna(0)


df["pos"] = build_signal(df).shift(1).fillna(0)
df["pnl"] = df["pos"] * df["ret"]
print(f"エントリー回数: {int((df['pos'].diff() == 1).sum())} 回")

lookback がこの実装の肝です。「RSIが売られすぎになってから10日以内にMACDが好転したら買う」という条件にすることで、順序を保ちながら取り逃しを防いでいます。

.shift(1) を必ず入れてください。その日の終値でシグナルを判定してその日に約定するのは不可能な取引です。この点はルックアヘッドバイアスをPythonで排除する方法で詳しく扱っています。

3つの方式を比較検証する

設計の違いが成績にどう出るのか、実際に並べて確認します。

COST = 0.001   # 往復0.1%


def evaluate(df, position, cost=COST, label=""):
    """コスト込みで成績を計算する"""
    pos = position.fillna(0)
    turnover = pos.diff().abs().fillna(0)
    pnl = (pos * df["ret"] - turnover * cost).fillna(0)

    eq = (1 + pnl).cumprod()
    trades = int((pos.diff() == 1).sum())
    active = pnl[pos != 0]

    return {
        "戦略": label,
        "累積%": round(float(eq.iloc[-1] - 1) * 100, 1),
        "最大DD%": round(float((eq / eq.cummax() - 1).min()) * 100, 1),
        "シャープ": round(
            float(pnl.mean() / pnl.std() * np.sqrt(252)) if pnl.std() else 0, 2),
        "売買": trades,
        "勝率%": round(float((active > 0).mean()) * 100, 1) if len(active) else 0,
    }


# 各方式のポジションを作る
oversold = df["rsi"] < 30
macd_turn = (df["hist"] > 0) & (df["hist"].shift(1) <= 0)
exit_sig = (df["rsi"] > 70) | ((df["hist"] < 0) & (df["hist"].shift(1) >= 0))

def make_pos(entry):
    s = pd.Series(np.nan, index=df.index)
    s[entry] = 1
    s[exit_sig] = 0
    return s.ffill().fillna(0).shift(1)

variants = {
    "RSI単独": make_pos(oversold),
    "MACD単独": make_pos(macd_turn),
    "AND条件": make_pos(oversold & macd_turn),
    "順序条件": build_signal(df).shift(1),
}

rows = [evaluate(df, pos, label=name) for name, pos in variants.items()]

# 比較のためBuy&Holdも
bh = (1 + df["ret"].fillna(0)).cumprod()
rows.append({
    "戦略": "Buy & Hold",
    "累積%": round(float(bh.iloc[-1] - 1) * 100, 1),
    "最大DD%": round(float((bh / bh.cummax() - 1).min()) * 100, 1),
    "シャープ": round(float(df["ret"].mean() / df["ret"].std() * np.sqrt(252)), 2),
    "売買": 1, "勝率%": 0,
})

print(pd.DataFrame(rows).to_string(index=False))

この比較で見えてくる傾向は、おおむね次のとおりです。

  • RSI単独: 売買回数は多いが、ドローダウンが深い
  • MACD単独: ダマシが多く、横ばい相場で削られる
  • AND条件: 売買回数が激減し、評価できるサンプルが集まらない
  • 順序条件: 回数を保ちつつ、ドローダウンが浅くなる

注目してほしいのは累積リターンではなく最大ドローダウンです。組み合わせの効果は「もっと儲かる」ではなく「同じくらいの利益を、より浅い含み損で取れる」という形で現れます。

これは実運用ではかなり重要です。ドローダウンが浅ければ、途中で耐えられずに投げ出す確率が下がります。評価指標としてはシャープレシオを併せて見るのが実務的です。

パラメータを触る前に確認すること

成績が思わしくないと、すぐにRSIの閾値を25にしたり、MACDの期間を変えたりしたくなります。その前に確認すべきことがあります。

相場環境ごとに成績を分けて見る

# 相場の状態別に成績を分解する
df["ma200"] = df["close"].rolling(200).mean()
df["regime"] = np.where(df["close"] > df["ma200"] * 1.03, "上昇",
                 np.where(df["close"] < df["ma200"] * 0.97, "下降", "レンジ"))

df["pnl_strategy"] = df["pos"] * df["ret"]
summary = df.dropna().groupby("regime")["pnl_strategy"].agg(
    日数="count",
    合計損益=lambda s: round(float((1 + s).prod() - 1) * 100, 1),
    平均="mean",
)
print(summary.to_string())

逆張り戦略は下降トレンド中に大きく削られるのが典型的なパターンです。ここが分かれば、パラメータをいじるより「下降局面ではエントリーしない」というフィルターを足すほうが効果的だと判断できます。

# 長期移動平均より上のときだけエントリーする
uptrend = df["close"] > df["ma200"]

filtered = build_signal(df)
filtered[~uptrend] = 0          # 下降局面ではポジションを持たない
filtered = filtered.ffill().fillna(0).shift(1)

rows = [
    evaluate(df, build_signal(df).shift(1), label="フィルターなし"),
    evaluate(df, filtered, label="200日線フィルターあり"),
]
print(pd.DataFrame(rows).to_string(index=False))

多くの場合、売買回数は減るのに成績は改善します。無駄なエントリーを削っているためです。「取引しない」という選択も戦略の一部だと分かる例です。

パラメータの安定性を確かめる

どうしても調整したい場合は、1点だけを狙わないことが重要です。周辺の値でも成績が保たれるかを確認してください。

rows = []
for entry in (25, 30, 35, 40):
    for lb in (5, 10, 15, 20):
        pos = build_signal(df, rsi_entry=entry, lookback=lb).shift(1)
        r = evaluate(df, pos)
        rows.append({"RSI閾値": entry, "許容日数": lb,
                     "シャープ": r["シャープ"], "売買": r["売買"]})

grid = pd.DataFrame(rows).pivot(index="RSI閾値", columns="許容日数",
                                values="シャープ")
print(grid.to_string())

vals = grid.values.ravel()
print(f"\n最良 {vals.max():.2f} / 平均 {vals.mean():.2f}")
if vals.max() - vals.mean() > 0.8:
    print("⚠ 特定の設定だけ突出。カーブフィッティングの疑いあり")

表全体がなだらかに良い数字なら信頼できますが、1マスだけ突出しているなら偶然です。この考え方はウォークフォワード分析でさらに厳密に検証できます。

複数銘柄で再現するか確かめる

1銘柄でうまくいっても、それが偶然である可能性は十分にあります。同じロジックが他の銘柄でも機能するか——これが最も実践的な検証です。

TICKERS = {
    "6501.T": "日立", "7203.T": "トヨタ", "6758.T": "ソニーG",
    "6367.T": "ダイキン", "6902.T": "デンソー", "9432.T": "NTT",
    "8306.T": "三菱UFJ", "4063.T": "信越化学",
}

results = []
for code, name in TICKERS.items():
    try:
        p = yf.download(code, period="5y", auto_adjust=True,
                        progress=False)["Close"].dropna()
        if len(p) < 500:
            continue
        d = pd.DataFrame({"close": p})
        d["ret"] = d["close"].pct_change()
        d["rsi"] = rsi(d["close"])
        d["macd"], d["signal"], d["hist"] = macd(d["close"])

        r = evaluate(d, build_signal(d).shift(1), label=name)
        bh_ret = float((1 + d["ret"].fillna(0)).prod() - 1) * 100
        r["B&H%"] = round(bh_ret, 1)
        r["勝敗"] = "○" if r["累積%"] > bh_ret else "×"
        results.append(r)
    except Exception:
        continue

res = pd.DataFrame(results)
print(res.to_string(index=False))
print(f"\nBuy&Holdに勝った銘柄 : {(res['勝敗'] == '○').sum()} / {len(res)}")
print(f"平均シャープ         : {res['シャープ'].mean():.2f}")

ここでの判断基準は明快です。8銘柄中1〜2銘柄でしか勝てないなら、その戦略には再現性がありません。たまたま相性の良い銘柄を見つけただけということになります。

逆に、成績にばらつきがあっても半分以上で機能しているなら、ロジックそのものには意味がある可能性が高くなります。

実運用に向けた注意点

RSIの計算方法は1つではない

意外な落とし穴です。RSIには単純平均を使う方式と、指数平滑を使うWilder方式があります。チャートツールで見ているRSIと自作コードの値が合わない、という事態がよく起こります。

def rsi_simple(close, period=14):
    """単純平均を使う方式"""
    delta = close.diff()
    gain = delta.clip(lower=0).rolling(period).mean()
    loss = (-delta.clip(upper=0)).rolling(period).mean()
    return 100 - 100 / (1 + gain / loss.replace(0, np.nan))


comp = pd.DataFrame({
    "Wilder方式": rsi(px),
    "単純平均方式": rsi_simple(px),
}).dropna()
print(comp.tail(5).round(2).to_string())
print(f"\n差の平均: {float((comp.iloc[:, 0] - comp.iloc[:, 1]).abs().mean()):.2f}")
print("※ 一般的なチャートツールはWilder方式です")

数ポイントの差ですが、閾値ちょうどの判定では結果が変わります。どちらを使うか決めて統一してください。

決算またぎに注意する

RSIが売られすぎを示していても、それが業績悪化を織り込んだ下げなら戻りません。特に決算発表をまたぐと、テクニカル指標の想定を超えた値動きが起こります。

実務的には「決算発表の前後3営業日はエントリーしない」というルールを入れるだけで、想定外の損失をかなり防げます。

損切りを必ず設定する

本記事の検証では、手仕舞い条件をRSIとMACDに任せています。しかし実運用では価格ベースの損切りが必須です。指標が好転しないまま下げ続けるケースがあるためです。

# 日次ボラティリティの2倍を損切り幅にする例
df["vol"] = df["ret"].rolling(20).std()
df["stop_width"] = df["close"] * df["vol"] * 2

latest = df.dropna().iloc[-1]
print(f"現在値     : {float(latest['close']):>9,.1f} 円")
print(f"日次ボラ   : {float(latest['vol']) * 100:>9.2f} %")
print(f"損切り幅   : {float(latest['stop_width']):>9,.1f} 円")
print(f"損切り価格 : "
      f"{float(latest['close'] - latest['stop_width']):>9,.1f} 円")

損切り幅の決め方は標準偏差を使ってポジションサイズと損切りを決める方法で詳しく扱っています。

まとめ

RSIとMACDを組み合わせた逆張り戦略の設計と検証をまとめました。

  • RSI単独は下落トレンド中に鳴り続けるため、深いドローダウンを招く
  • RSIは「強さ」、MACDは「向き」を測る。測っているものが違うから補完できる
  • AND条件では取り逃す。「RSIが先、MACDが後」の順序条件が理にかなっている
  • 組み合わせの効果は利益増ではなくドローダウンの浅さに現れる
  • 成績が悪いときはパラメータより先に相場環境フィルターを検討する
  • 複数銘柄で再現するかを必ず確認する。1銘柄の好成績は偶然の可能性が高い
  • RSIの計算方式は2種類ある。実運用では価格ベースの損切りが必須

指標を組み合わせるとき大切なのは、「なぜその2つなのか」を説明できることだと思います。何となく2つ重ねれば精度が上がるわけではありません。RSIとMACDが噛み合うのは、性格の違う指標だからです。

逆に、RSIとストキャスティクスのように似た性格の指標を重ねても意味はありません。同じことを2回確認しているだけになります。この視点を持つと、指標の足し算に振り回されなくなります。

まずは本記事のコードを自分の保有銘柄で走らせて、RSI単独と順序条件のドローダウンを比べてみてください。累積リターンはあまり変わらないのに、含み損の深さが明確に違うはずです。その差が、指標を組み合わせる本当の価値になります。

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