去年の決算発表日、日本製鉄を保有していた。
前日の夜に「業績上方修正の噂」をどこかで読んで、そのまま寝た。翌朝起きたら-8%。噂は噂だった。
その後、「そもそも製造メーカー株の決算って、期待値的にプラスなんだろうか?」という疑問が頭から離れなくなった。Pythonで調べた結論を先に書く。期待値はほぼゼロ、勝率は48%。ランダムと区別がつかなかった。ただし、ボラティリティだけは平常時の2倍以上に跳ね上がっていた。
つまり決算ホールドは、リターンの上乗せなしにリスクだけを2倍にする取引だった。この記事では、その検証コードを全部載せる。前回は変数の定義を省いていたので、今回はそのまま動く形にした。
いちばん重要なのは「発表時刻」だ
コードを書く前に、決定的な落とし穴を1つ潰しておく。決算の発表時刻によって、反応する日が変わる。
| 発表時刻 | 市場の反応日 | 測るべきリターン |
|---|---|---|
| 15:00より前(場中) | 当日の後場から | 当日終値〜翌日終値 |
| 15:00以降(引け後) | 翌営業日の寄りから | 翌日始値〜翌々日終値 |
ここを一律に「翌日の寄り」で計算すると、場中発表の分はすでに動いた後の価格で買ったことにしてしまう。逆に一律「当日終値」で買うと、引け後発表の分は未来を先取りしている。どちらもlookaheadか機会損失のどちらかになる。
日本株の場合、大半は15時以降の発表だが、一定数は場中に出る。J-Quantsの財務情報にはDiscDateとDiscTimeの両方が入っているので、これで分岐できる。
決算日を集める
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_ratio:2.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倍」と分かっただけで、決算前に眠れない夜は減った。次は決算後のボラ収縮を使えないか試すつもりだ。ボラティリティは、リターンより素直に動く。

