ゴールデンクロス戦略を検証|トヨタ・日立で15年分

基礎知識・戦略

「トヨタや日立みたいな大型製造株にテクニカルって効くの?」と半信半疑だったので、ゴールデンクロス戦略をPythonで検証しました。

で、最初に出た結果がひどいマイナスでした。チャートを見ると、日立で謎の暴落が起きていて、そこでデッドクロスして損切りしています。調べたら株式分割でした。分割日に株価が5分の1になり、それを暴落として認識していたわけです。

この記事では、その修正を含めてまともに検証できる枠組みを作り、4銘柄で回します。あと、記事のあとに書こうと思っていた「決算をまたがない」フィルタも実装して、効果があるのか測りました。

まず、未調整株価で検証してはいけない

yf.download()は現在auto_adjust=Trueが既定なので普通は起きませんが、古い記事のコードにはauto_adjust=Falseが残っていることがあります。その状態で分割のある銘柄を検証すると、結果が壊れます。

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

def check_splits(ticker: str, start: str = "2015-01-01") -> None:
    """分割履歴を確認する。ここに日付があるなら調整済み価格が必須。"""
    sp = yf.Ticker(ticker).splits
    sp = sp[sp.index >= start]
    if len(sp) == 0:
        print(f"{ticker}: 分割なし")
        return
    for d, ratio in sp.items():
        print(f"{ticker}: {d.date()} に 1:{ratio:g} 分割")

for t in ["7203.T", "6501.T", "6752.T", "6981.T"]:
    check_splits(t)

実行すると、大型製造株のいくつかに分割履歴が出ます。分割日に価格が数分の1になれば、25日線は必ず75日線を割ります。存在しない暴落で全銘柄が損切りされ、そのあと二度と買いシグナルが出ない、という結果になります。

def fake_crash_demo(ticker: str = "6501.T") -> None:
    raw = yf.download(ticker, period="10y", auto_adjust=False,
                      progress=False, multi_level_index=False)["Close"]
    adj = yf.download(ticker, period="10y", auto_adjust=True,
                      progress=False, multi_level_index=False)["Close"]

    r_raw, r_adj = raw.pct_change(), adj.pct_change()
    print(f"未調整の最悪日: {r_raw.min() * 100:.1f}%  "
          f"({r_raw.idxmin().date()})")
    print(f"調整済の最悪日: {r_adj.min() * 100:.1f}%  "
          f"({r_adj.idxmin().date()})")

fake_crash_demo()

未調整だと「1日で-80%」といった値が出ます。これが起きていたら、それは相場ではなくデータの問題です。

まともなバックテストの枠組み

元のコードには、分割以外にも問題がありました。

問題影響修正
クロス日の終値で約定成績が過大に出る翌日の始値で約定
手数料・スリッページなし同上往復0.1〜0.2%を計上
Buy&Holdと比較しない意味を判断できない必ず併記
検証期間が3年クロスが数回しかない10年以上
1銘柄ずつ目視都合の良い解釈をする複数銘柄を表で比較
def load(ticker: str, period: str = "15y") -> pd.DataFrame:
    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.dropna()

def backtest(df: pd.DataFrame, short: int = 25, long: int = 75,
             cash: float = 1_000_000, cost: float = 0.001,
             unit: int = 100) -> dict:
    """翌日始値で約定する単元株バックテスト。"""
    c, o = df["Close"], df["Open"]
    ma_s, ma_l = c.rolling(short).mean(), c.rolling(long).mean()

    # 当日終値で判定 → 翌日の寄付で執行
    want = (ma_s > ma_l).astype(int)
    signal = want.diff()

    equity, shares, trades = cash, 0, []
    entry_px = 0.0

    for i in range(long + 1, len(df) - 1):
        px = float(o.iloc[i + 1])          # 翌日の始値
        if signal.iloc[i] == 1 and shares == 0:
            n = int(equity / (px * (1 + cost)) / unit) * unit
            if n > 0:
                equity -= n * px * (1 + cost)
                shares, entry_px = n, px
        elif signal.iloc[i] == -1 and shares > 0:
            equity += shares * px * (1 - cost)
            trades.append({
                "exit": df.index[i + 1],
                "pnl_pct": px / entry_px - 1 - 2 * cost,
            })
            shares = 0

    final = equity + shares * float(c.iloc[-1])
    bh = cash * float(c.iloc[-1]) / float(c.iloc[long])

    t = pd.DataFrame(trades)
    years = len(df) / 252
    return {
        "戦略%": round((final / cash - 1) * 100, 1),
        "B&H%": round((bh / cash - 1) * 100, 1),
        "戦略CAGR%": round(((final / cash) ** (1 / years) - 1) * 100, 2),
        "B&H_CAGR%": round(((bh / cash) ** (1 / years) - 1) * 100, 2),
        "取引数": len(t),
        "勝率%": round((t["pnl_pct"] > 0).mean() * 100, 1) if len(t) else 0.0,
        "平均損益%": round(t["pnl_pct"].mean() * 100, 2) if len(t) else 0.0,
        "建玉中の未決済": shares > 0,
    }

o.iloc[i + 1]翌日の始値を使っているのが要点です。終値でシグナルが確定した瞬間に同じ終値で買うことはできません。ここを直すだけで、成績は目に見えて落ちます。

4銘柄で回してみる

NAMES = {"7203.T": "トヨタ", "6501.T": "日立",
         "6752.T": "パナソニック", "6981.T": "村田製作所"}

rows = []
for t, name in NAMES.items():
    r = backtest(load(t))
    r["銘柄"] = name
    rows.append(r)

res = pd.DataFrame(rows).set_index("銘柄")
print(res[["戦略CAGR%", "B&H_CAGR%", "取引数", "勝率%", "平均損益%"]])
print(f"\nB&Hに勝った銘柄: {int((res['戦略CAGR%'] > res['B&H_CAGR%']).sum())}/{len(res)}")

結果はだいたいこうなります。銘柄によって勝ったり負けたりで、全体としてはBuy&Holdとどっこい。15年で取引回数は20〜30回程度でした。

ここで取引回数を見てください。15年で25回です。勝率60%と出ても、25回中15勝。コイントスでも十分起こる範囲です。

from scipy import stats

def significance(res: pd.DataFrame) -> None:
    for name, row in res.iterrows():
        n = int(row["取引数"])
        w = int(round(n * row["勝率%"] / 100))
        if n == 0:
            continue
        p = stats.binomtest(w, n, 0.5, alternative="greater").pvalue
        print(f"{name:8s} {w}/{n}勝  p={p:.3f}  "
              + ("有意" if p < 0.05 else "偶然の範囲"))

significance(res)

ほぼ全銘柄で「偶然の範囲」と出ます。1銘柄あたりの検証では、そもそも結論を出せる標本数がありません。「日立は相性がいい」「パナソニックは苦手」という感想は、この時点では言えないことになります。

市場滞在率で見ると評価が変わる

ただ、CAGRだけで切り捨てるのも早計です。この戦略は下落局面でキャッシュに逃げるので、市場に晒している時間が短いという特徴があります。

def risk_profile(df: pd.DataFrame, short: int = 25, long: int = 75,
                 cost: float = 0.001) -> dict:
    c = df["Close"]
    r = c.pct_change().fillna(0)
    pos = (c.rolling(short).mean() > c.rolling(long).mean()).astype(float)
    pos = pos.shift(1).fillna(0)                 # 翌日から建てる
    pnl = pos * r - pos.diff().abs().fillna(0) * cost

    def stats_of(s: pd.Series) -> dict:
        eq = (1 + s).cumprod()
        return {
            "Sharpe": round(s.mean() / s.std() * np.sqrt(252), 2),
            "最大DD%": round((eq / eq.cummax() - 1).min() * 100, 1),
        }

    out = {"滞在率%": round(pos.mean() * 100, 1)}
    out.update({f"戦略_{k}": v for k, v in stats_of(pnl).items()})
    out.update({f"B&H_{k}": v for k, v in stats_of(r).items()})
    return out

prof = pd.DataFrame({name: risk_profile(load(t))
                     for t, name in NAMES.items()}).T
print(prof)

市場滞在率は6割前後になります。最大ドローダウンはBuy&Holdより明確に浅いので、リーマンショックやコロナショックのような局面は避けられています。

リターンが同程度で、下落幅が半分、しかも4割の期間は現金。この現金を他の銘柄に回せるなら、資本効率としては意味があります。1銘柄だけを見ていると「勝てない戦略」に見えますが、ポートフォリオの部品としては評価が変わります。

決算をまたがないフィルタを試す

製造株は決算で大きく飛びます。「決算前はポジションを持たない」というルールに効果があるのか、実際に測りました。

def earnings_dates(ticker: str) -> pd.DatetimeIndex:
    """yfinanceから決算日を取得する(取れないことも多い)。"""
    try:
        ed = yf.Ticker(ticker).get_earnings_dates(limit=60)
        if ed is None or ed.empty:
            return pd.DatetimeIndex([])
        idx = pd.to_datetime(ed.index).tz_localize(None).normalize()
        return pd.DatetimeIndex(sorted(set(idx)))
    except Exception:
        return pd.DatetimeIndex([])

def with_earnings_filter(df: pd.DataFrame, ed: pd.DatetimeIndex,
                         before: int = 3, after: int = 1,
                         short: int = 25, long: int = 75,
                         cost: float = 0.001) -> dict:
    c = df["Close"]
    r = c.pct_change().fillna(0)
    raw_pos = (c.rolling(short).mean() > c.rolling(long).mean()).astype(float)

    # 決算日の前後を「持たない日」にする
    block = pd.Series(False, index=df.index)
    for d in ed:
        lo = d - pd.Timedelta(days=before * 2)     # 営業日ベースで概算
        hi = d + pd.Timedelta(days=after * 2)
        block.loc[(df.index >= lo) & (df.index <= hi)] = True

    filtered = raw_pos.where(~block, 0.0)

    out = {}
    for name, p in [("フィルタなし", raw_pos), ("決算回避", filtered)]:
        pos = p.shift(1).fillna(0)
        pnl = pos * r - pos.diff().abs().fillna(0) * cost
        eq = (1 + pnl).cumprod()
        out[name] = {
            "累積%": round((eq.iloc[-1] - 1) * 100, 1),
            "Sharpe": round(pnl.mean() / pnl.std() * np.sqrt(252), 2),
            "最大DD%": round((eq / eq.cummax() - 1).min() * 100, 1),
            "売買回数": int(pos.diff().abs().sum()),
        }
    return out

for t, name in NAMES.items():
    ed = earnings_dates(t)
    if len(ed) == 0:
        print(f"{name}: 決算日が取得できませんでした")
        continue
    print(f"\n=== {name}(決算日 {len(ed)}件)===")
    print(pd.DataFrame(with_earnings_filter(load(t, "5y"), ed)).T)

やってみた感想としては、最大ドローダウンは少し浅くなるが、リターンも一緒に削れるという結果でした。決算で下に飛ぶのを避けられる代わりに、上に飛ぶのも取り逃がします。

ただし売買回数が増えるのが厄介です。決算のたびに手仕舞って建て直すので、コストが積み上がります。「決算をまたがない」は精神衛生には良くても、期待値としては微妙、というのが自分の結論でした。

なお、get_earnings_dates()は日本株では取得できないことが多いです。真面目にやるならJ-Quants APIの決算発表予定日を使ってください。こちらは全上場銘柄ぶん揃っています。

パラメータ最適化をやる前に

「25日・75日を最適化してベスト設定を探す」というのは自然な発想ですが、取引回数が20〜30回の状況でやると、ほぼ確実にノイズを拾います。

def param_grid(df: pd.DataFrame) -> pd.DataFrame:
    rows = []
    for s in (5, 10, 25, 40):
        for l in (50, 75, 100, 150, 200):
            if s >= l:
                continue
            r = risk_profile(df, s, l)
            rows.append({"short": s, "long": l, "Sharpe": r["戦略_Sharpe"]})
    return pd.DataFrame(rows).pivot(index="short", columns="long",
                                    values="Sharpe")

print(param_grid(load("7203.T")))

表を眺めると、良い数字が1マスだけ孤立していることがよくあります。隣のマスがマイナスなら、その1マスは偶然です。判断するなら「良い領域が面で広がっているか」を見てください。

まとめ

  • 必ずauto_adjust=Trueを使う。分割が偽の暴落になる
  • 約定は翌日の始値。クロス日の終値では買えない
  • 15年で取引20〜30回。この標本数では勝率に意味がない
  • CAGRだけでなく市場滞在率と最大ドローダウンを見る
  • 決算回避フィルタはドローダウンを削るがリターンも削る。売買回数の増加に注意
  • 取引回数が少ないうちにパラメータ最適化をしても無駄

結局のところ、単体のゴールデンクロス戦略でBuy&Holdを大きく上回るのは難しい、というのが検証の答えでした。ただ、ドローダウンが浅く、4割の期間は現金でいられるという性質は使いどころがあります。

次は1銘柄ずつではなく、製造株を10〜20銘柄まとめて同じルールで回して、ポートフォリオ全体で評価してみるつもりです。銘柄数を増やせば取引回数も増えるので、ようやく統計的な話ができるようになるはずです。

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