決算またぎの期待値をPythonで検証|製造株4銘柄の実測

基礎知識・戦略

去年の決算発表日、日本製鉄を保有していた。

前日の夜に「業績上方修正の噂」をどこかで読んで、そのまま寝た。翌朝起きたら-8%。噂は噂だった。

その後、「そもそも製造メーカー株の決算って、期待値的にプラスなんだろうか?」という疑問が頭から離れなくなった。Pythonで調べた結論を先に書く。期待値はほぼゼロ、勝率は48%。ランダムと区別がつかなかった。ただし、ボラティリティだけは平常時の2倍以上に跳ね上がっていた。

つまり決算ホールドは、リターンの上乗せなしにリスクだけを2倍にする取引だった。この記事では、その検証コードを全部載せる。前回は変数の定義を省いていたので、今回はそのまま動く形にした。

いちばん重要なのは「発表時刻」だ

コードを書く前に、決定的な落とし穴を1つ潰しておく。決算の発表時刻によって、反応する日が変わる。

発表時刻市場の反応日測るべきリターン
15:00より前(場中)当日の後場から当日終値〜翌日終値
15:00以降(引け後)翌営業日の寄りから翌日始値〜翌々日終値

ここを一律に「翌日の寄り」で計算すると、場中発表の分はすでに動いた後の価格で買ったことにしてしまう。逆に一律「当日終値」で買うと、引け後発表の分は未来を先取りしている。どちらもlookaheadか機会損失のどちらかになる。

日本株の場合、大半は15時以降の発表だが、一定数は場中に出る。J-Quantsの財務情報にはDiscDateDiscTimeの両方が入っているので、これで分岐できる。

決算日を集める

import os
import time
import numpy as np
import pandas as pd
import requests

BASE = "https://api.jquants.com/v2"

class JQuants:
    def __init__(self, api_key: str, per_minute: int = 5):
        self.s = requests.Session()
        self.s.headers.update({"x-api-key": api_key})
        self.interval = 60 / per_minute
        self._last = 0.0

    def get(self, path: str, **params) -> list[dict]:
        rows, key = [], None
        while True:
            wait = self.interval - (time.time() - self._last)
            if wait > 0:
                time.sleep(wait)
            if key:
                params["pagination_key"] = key
            r = self.s.get(f"{BASE}{path}", params=params, timeout=30)
            self._last = time.time()
            r.raise_for_status()
            body = r.json()
            rows += body.get("data", [])
            key = body.get("pagination_key")
            if not key:
                return rows

cli = JQuants(os.getenv("JQUANTS_API_KEY"))

TARGETS = {
    "72030": "トヨタ自動車",
    "65010": "日立製作所",
    "70110": "三菱重工業",
    "54010": "日本製鉄",
}

def earnings_dates(cli: JQuants, code: str) -> pd.DataFrame:
    rows = cli.get("/fins/summary", code=code)
    df = pd.DataFrame(rows)
    df["disc_date"] = pd.to_datetime(df["DiscDate"])
    df["disc_time"] = df["DiscTime"].replace("", None)
    # 15時以降の発表か
    df["after_close"] = df["disc_time"].map(
        lambda t: True if t is None else t >= "15:00:00"
    )
    return df[["disc_date", "disc_time", "after_close", "CurPerType"]].drop_duplicates()

J-Quantsを使わない場合、yfinanceのticker.earnings_datesでも近い情報は取れる。ただし日本株はカバーが薄く、時刻も入らない。時刻が取れないなら「全部引け後」と仮定するしかないが、その前提を明示しておくべきだ。

イベント前後のリターンを測る

import yfinance as yf

def load_ohlc(ticker: str, start: str = "2018-01-01") -> pd.DataFrame:
    df = yf.download(ticker, start=start, auto_adjust=True, progress=False)
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)
    df = df[["Open", "High", "Low", "Close"]].dropna()
    df.index = pd.to_datetime(df.index).tz_localize(None)
    return df.sort_index()

def event_returns(px: pd.DataFrame, events: pd.DataFrame) -> pd.DataFrame:
    idx = px.index
    rows = []
    for _, ev in events.iterrows():
        d = ev["disc_date"]
        pos = idx.searchsorted(d)
        if pos >= len(idx) - 3 or pos < 2:
            continue

        if ev["after_close"]:
            # 引け後発表:翌営業日の寄りで入る
            entry_i, base_i = pos + 1, pos
            entry = px["Open"].iloc[entry_i]
        else:
            # 場中発表:当日の引けで入る(後場の反応は取り逃す前提)
            entry_i, base_i = pos, pos
            entry = px["Close"].iloc[entry_i]

        prev_close = px["Close"].iloc[base_i - 1]
        gap = px["Open"].iloc[pos + 1] / px["Close"].iloc[pos] - 1

        rows.append({
            "date": d.date(),
            "after_close": ev["after_close"],
            "period": ev["CurPerType"],
            "gap_pct": gap * 100,
            "r_1d": (px["Close"].iloc[entry_i + 1] / entry - 1) * 100,
            "r_2d": (px["Close"].iloc[min(entry_i + 2, len(idx) - 1)] / entry - 1) * 100,
            "r_5d": (px["Close"].iloc[min(entry_i + 5, len(idx) - 1)] / entry - 1) * 100,
            "prev_close": float(prev_close),
        })
    return pd.DataFrame(rows)

searchsortedを使っているのは、決算日が休日にあたるケースを吸収するためだ。dates.index(ed)のような完全一致だと、そこでValueErrorになって静かにスキップされる。前回のコードはそれで数件を取りこぼしていた。

平常時と比べる

ここが肝心だ。決算後のリターンが平均-0.4%だったとして、それが多いのか少ないのか。決算と無関係な日の分布と比べないと判断できない。

from scipy import stats

def compare_with_baseline(px: pd.DataFrame, ev: pd.DataFrame, horizon: int = 2) -> dict:
    col = f"r_{horizon}d"
    event_ret = ev[col].dropna().values

    # 平常時:同じ保有期間のリターン分布(決算日の前後5営業日は除外)
    all_ret = (px["Close"].shift(-horizon) / px["Open"].shift(-1) - 1).dropna() * 100
    ev_dates = pd.to_datetime(ev["date"])
    mask = pd.Series(True, index=all_ret.index)
    for d in ev_dates:
        mask.loc[(all_ret.index >= d - pd.Timedelta(days=7)) &
                 (all_ret.index <= d + pd.Timedelta(days=7))] = False
    base_ret = all_ret[mask].values

    t, p = stats.ttest_ind(event_ret, base_ret, equal_var=False)
    return {
        "n_event": len(event_ret),
        "event_mean": round(float(event_ret.mean()), 3),
        "base_mean": round(float(base_ret.mean()), 3),
        "event_std": round(float(event_ret.std(ddof=1)), 3),
        "base_std": round(float(base_ret.std(ddof=1)), 3),
        "vol_ratio": round(float(event_ret.std(ddof=1) / base_ret.std(ddof=1)), 2),
        "win_rate": round(float((event_ret > 0).mean()), 3),
        "t_stat": round(float(t), 2),
        "p_value": round(float(p), 4),
    }

4銘柄で回すとこうなる。

rows = []
for code, name in TARGETS.items():
    px = load_ohlc(f"{code[:4]}.T")
    ev = event_returns(px, earnings_dates(cli, code))
    if ev.empty:
        continue
    res = compare_with_baseline(px, ev)
    res["name"] = name
    rows.append(res)

summary = pd.DataFrame(rows).set_index("name")
print(summary)

結果:リターンはゼロ、リスクだけ2倍

4銘柄をまとめた傾向はこうだった。

  • 平均リターン:-0.4%(コストを入れると-0.8%程度)
  • 勝率:48%
  • vol_ratio2.1〜2.6倍
  • p_value:0.3前後。平常時との差は統計的に有意ではない

p値が0.3ということは、平均-0.4%という数字自体が偶然の範囲だということだ。「決算後は下がりやすい」とは言えない。逆に「上がりやすい」とも言えない。

一方、vol_ratioだけは全銘柄で明確に2倍を超えた。これは偶然ではない。期待リターンは変わらないのに、ブレ幅だけが2倍になる。シャープレシオで考えれば、決算をまたぐだけで単位リスクあたりの効率が半分以下になる。

n数が問題になる点にも触れておく。1銘柄あたり年4回、7年で28回しかない。4銘柄合わせても112回。この規模では、平均リターンの差はまず検出できない。逆にボラティリティの差は少ないサンプルでも出る。だから「リターンについては分からない、リスクについては明確」というのが、データから言える限界だ。

ギャップの分布を見る

def gap_distribution(ev: pd.DataFrame) -> dict:
    g = ev["gap_pct"].dropna()
    return {
        "n": len(g),
        "mean": round(float(g.mean()), 2),
        "median": round(float(g.median()), 2),
        "over_5pct": int((g.abs() > 5).sum()),
        "over_8pct": int((g.abs() > 8).sum()),
        "worst": round(float(g.min()), 2),
        "best": round(float(g.max()), 2),
    }

僕が食らった-8%は、外れ値ではなかった。4銘柄112回のうち、絶対値5%超のギャップは20回近くある。6回に1回は5%動く。それを知らずに「上がりそうだから」で持ち越していた。

決算後にボラは収縮するのか

最後に、次の検証の入り口も測っておいた。決算前に膨らんだボラティリティが、発表後にどれだけ縮むかだ。

def vol_around_event(px: pd.DataFrame, ev: pd.DataFrame, window: int = 10) -> pd.DataFrame:
    ret = px["Close"].pct_change()
    rows = []
    for d in pd.to_datetime(ev["date"]):
        pos = px.index.searchsorted(d)
        if pos < window or pos + window * 2 >= len(px):
            continue
        rows.append({
            "before": float(ret.iloc[pos - window:pos].std() * 100),
            "after": float(ret.iloc[pos + 2:pos + 2 + window].std() * 100),
        })
    df = pd.DataFrame(rows)
    df["shrink_pct"] = (1 - df["after"] / df["before"]) * 100
    return df.describe().round(2)

手元ではshrink_pctの中央値がプラスになった。決算前は不確実性でボラが上がり、事実が出ると落ち着く。オプションのIVクラッシュと同じ構図が、現物のヒストリカルボラでも観測できる。

じゃあ決算をどう扱うか

有効だったのは決算をリスク回避に使う考え方だ。儲けに行くのではなく、避ける対象として扱う。

def build_earnings_blackout(cli: JQuants, codes: dict,
                            before: int = 3, after: int = 1) -> set:
    """決算前後の営業日をエントリー禁止日として集合で返す。"""
    blackout = set()
    for code in codes:
        ev = earnings_dates(cli, code)
        for d in ev["disc_date"]:
            window = pd.bdate_range(d - pd.tseries.offsets.BDay(before),
                                    d + pd.tseries.offsets.BDay(after))
            blackout |= {(code, ts.date()) for ts in window}
    return blackout

def can_enter(code: str, date, blackout: set) -> bool:
    return (code, pd.Timestamp(date).date()) not in blackout

これをエントリー条件に足すだけで、バックテストのシャープレシオが0.3ほど改善した。理由は単純で、リターンの期待値を変えずに分母(ボラティリティ)だけを削っているからだ。前節のvol_ratioが2倍という数字と、きれいに整合する。

ただし注意がある。この改善は「決算前後を避けた」効果であって、戦略が良くなったわけではない。ポジションを持たない期間が増えれば、当然ブレは減る。市場滞在率もあわせて確認すべきだ。その見方はバックテスト実装の記事に書いた。

まとめ

日本製鉄-8%の教訓は「決算前に感情でポジションを持つな」だった。定量化して分かったのは、それが感情の問題ですらなく、構造的に割に合わない賭けだということだ。

検証で押さえるべき点は3つ。発表時刻で反応日が変わるのでDiscTimeで分岐する。平常時の分布と比較して、有意差があるかを確認する。n数が小さいので、平均の差より分散の差を見る。

期待値がプラスだと示せなかったのは残念だが、「リスクだけ2倍」と分かっただけで、決算前に眠れない夜は減った。次は決算後のボラ収縮を使えないか試すつもりだ。ボラティリティは、リターンより素直に動く。

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