東証プライムの1,534銘柄について、当日の夜間・日中リターンが翌日の各セッションにどう伝わるかを分析してみました。
ま、これ↑の続きですね。
- 最も一貫していたのは、日中に上がった銘柄ほど翌日の日中にわずかに反落する関係
- ただし効果は小さく、前日の日中リターンが +1% のとき、翌日の日中リターンは中央値で約 -2.36 bps(-0.0236%)だけ変化する程度
- 回帰の決定係数は中央値で約 0.2%。つまり、翌日の値動きのほとんどはこのモデルでは説明できません
- HAC標準誤差とFDR補正を使うと、大半の銘柄は個別には有意ではありませんでした
- 期間分割では係数が変化し、特に翌日の夜間へつながる経路は不安定
- 極端な値動きを除いても「夜間は平均的にプラス、日中は平均的にマイナス」というセッション別の水準差は残りました
結論は、lag1の構造は観測できるが、これだけで売買可能なエッジが見つかったとはいえない、って感じです。
前回は、夜間リターンと日中リターンの長期的な偏りを表す SessionTilt を東証プライム全体に適用しました。
- 夜間リターン $ON$:前日終値から当日始値まで
- 日中リターン $ID$:当日始値から当日終値まで
- SessionTilt:その銘柄のリターンが、長期的に夜間と日中のどちらへ偏っているか
その結果、2026年6月15日時点で、分析可能な銘柄の87.3%が「平滑化した夜間リターンのほうが日中リターンより強い」状態でした。
その後、元記事の筆者から次の意見をいただきました。
前日の日中の値動きが、翌日の夜間や終値間リターンへ遅れて効く成分を分離すると、銘柄ごとの価格形成がさらに見えてくるかもしれません。
なるほど、言われてみれば確かにそれはありそうです。
ということで今回は、SessionTiltという長期的な水準から一歩進み、1営業日遅れて現れる関係、つまり lag1 を調べるぜ。なるべく私と同じような初心者でもわかるように説明入れていきます(まあ、そもそもが難しい内容だから初心者って言っても、という感じな気はしますが……)。
まず、lag1とは何か
lag は「遅れ」という意味です。ラグですラグ。
lag1 は1期遅れ、日次データなら通常は「1営業日前」を指します。
月曜日と火曜日を例にします。
| 記号 | 区間例 | 説明 |
|---|---|---|
| $ON_t$ | 前日終値 → 月曜始値 | 月曜の夜間リターン |
| $ID_t$ | 月曜始値 → 月曜終値 | 月曜の日中リターン |
| $ON_{t+1}$ | 月曜終値 → 火曜始値 | 翌日の夜間リターン |
| $ID_{t+1}$ | 火曜始値 → 火曜終値 | 翌日の日中リターン |
今回の問いは、たとえば次のようなものです。
月曜日の日中に大きく上がった銘柄は、火曜日の寄り付きまでにも上がり続けるのか。それとも火曜日の日中に反落するのか。
注意したいのは、火曜日の $ON$ と $ID$ は異なる時間帯のリターンだという点です。翌日の終値間リターンだけを見るより、どのセッションで価格調整が起きたかを分けて観察できます。
ちなみに、
- $ID$ は、Intraday return 日中リターン
- $ON$ は、Overnight return 夜間リターン
- $CC$ は、Close-to-Close return 当日の終値から翌日の終値
です。
リターンを夜間と日中に分解する
本分析では加法性を保つため、対数リターンを使いました。
$$
ON_t = \log\left(\frac{Open_t}{Close_{t-1}}\right)
$$
$$
ID_t = \log\left(\frac{Close_t}{Open_t}\right)
$$
$$
CC_t = \log\left(\frac{Close_t}{Close_{t-1}}\right)
$$
したがって、厳密に次が成り立ちます。
$$
CC_t = ON_t + ID_t
$$
翌日も同じです。
$$
CC_{t+1} = ON_{t+1} + ID_{t+1}
$$
実データで確認した最大計算誤差は、当日・翌日ともに 0.0 でしたので、使って問題なさそうです。
import numpy as np
df = df.sort_values(["Code", "Date"]).copy()
g = df.groupby("Code", group_keys=False)
df["ON"] = np.log(df["Open"] / g["Close"].shift(1))
df["ID"] = np.log(df["Close"] / df["Open"])
df["CC"] = np.log(df["Close"] / g["Close"].shift(1))
df["ON_next"] = g["ON"].shift(-1)
df["ID_next"] = g["ID"].shift(-1)
df["CC_next"] = g["CC"].shift(-1)
df["NextDate"] = g["Date"].shift(-1)
ここでの shift(-1) は、同じ銘柄の次の観測行を翌営業日の列として横に並べる処理です。金曜日の次は月曜日なので、単純な暦日ではなく銘柄ごとの取引日の順序を使います。
| 項目 | 値 |
|---|---|
| 対象 | 東証プライムの内国普通株 |
| lag1分析対象 | 1,534銘柄 |
| 元の価格データ | 3,585,067行 |
| lag1分析可能データ | 3,583,533行 |
| 分析期間 | 2016年9月12日〜2026年6月15日 |
| 株価 | Yahoo Financeを yfinance で取得 |
前回と同様に、対象銘柄はJ-Quantsの銘柄マスターから作り、株価は yfinance で取得した調整済みOHLCを使いました。現在のプライム銘柄を過去へさかのぼらせた分析なので、後述する生存者バイアスがあります。なんでこんなことをしたのかってのは、私はビンボーでケチなので、J-Quantsが無料プランゆえ、2年分しかデータが取れないからです(いや課金しろっておれも思ってる)
1. まず単純な相関を見る
最初に、前日の日中リターン $ID_t$ と翌日の各リターンの相関を銘柄ごとに計算しました。
# 前日の日中 → 翌日リターンの相関
MIN_LAG_OBS = 500
correlation_columns = [
"ID",
"ON_next",
"ID_next",
"CC_next",
]
def calculate_stock_correlations(x):
return pd.Series({
"Observations": len(x),
"Corr_ID_to_ON_next": (
x["ID"].corr(x["ON_next"])
),
"Corr_ID_to_ID_next": (
x["ID"].corr(x["ID_next"])
),
"Corr_ID_to_CC_next": (
x["ID"].corr(x["CC_next"])
),
})
# 銘柄ごとの時系列相関
stock_correlations = (
lag1_data
.groupby("YahooTicker", sort=False)[
correlation_columns
]
.apply(calculate_stock_correlations)
.reset_index()
)
# 十分なlag1観測数がある銘柄だけ採用
stock_correlations = (
stock_correlations.loc[
stock_correlations["Observations"]
>= MIN_LAG_OBS
]
.reset_index(drop=True)
)
relations = [
(
"前日の日中 → 翌日の夜間",
"ON_next",
"Corr_ID_to_ON_next",
),
(
"前日の日中 → 翌日の日中",
"ID_next",
"Corr_ID_to_ID_next",
),
(
"前日の日中 → 翌日の終値間",
"CC_next",
"Corr_ID_to_CC_next",
),
]
summary_rows = []
for label, target, corr_column in relations:
correlations = (
stock_correlations[corr_column]
.dropna()
)
# 全銘柄・全時点をまとめた参考値
pooled_correlation = (
lag1_data["ID"]
.corr(lag1_data[target])
)
summary_rows.append({
"関係": label,
"銘柄数": len(correlations),
"銘柄別相関の中央値": correlations.median(),
"第1四分位": correlations.quantile(0.25),
"第3四分位": correlations.quantile(0.75),
"相関プラス銘柄割合": (
correlations > 0
).mean(),
"プール相関・参考": pooled_correlation,
})
correlation_summary = pd.DataFrame(
summary_rows
)
display(
correlation_summary.style.format({
"銘柄別相関の中央値": "{:+.4f}",
"第1四分位": "{:+.4f}",
"第3四分位": "{:+.4f}",
"相関プラス銘柄割合": "{:.1%}",
"プール相関・参考": "{:+.4f}",
})
)
print(
"相関計算に採用した銘柄数:",
len(stock_correlations)
)
# 保存
correlation_file = (
OUTPUT_DIR
/ "lag1_stock_correlations.csv"
)
stock_correlations.to_csv(
correlation_file,
index=False,
encoding="utf-8-sig",
)
print("保存先:", correlation_file)
| 関係 | 銘柄別相関の中央値 | 第1四分位 | 第3四分位 | 相関がプラスの銘柄割合 | プール相関(参考) |
|---|---|---|---|---|---|
| 前日の日中 → 翌日の夜間 | -0.0071 | -0.0370 | +0.0230 | 43.0% | +0.0012 |
| 前日の日中 → 翌日の日中 | -0.0232 | -0.0457 | -0.0014 | 23.7% | -0.0250 |
| 前日の日中 → 翌日の終値間 | -0.0225 | -0.0455 | +0.0012 | 25.9% | -0.0120 |
方向として最もそろっているのは、$ID_t → ID_{t+1}$ のマイナスです。76.3%の銘柄で相関が負でした。
ただし、中央値は -0.0232 しかありません。「広い銘柄で符号は似ているが、関係は非常に弱い」というのが第一印象です。
なお、すべての銘柄・日付をまとめて計算するプール相関は、値動きの大きい銘柄や観測数の多い銘柄の影響を受けてしまいます。今回は「典型的な銘柄」を見るため、銘柄ごとに計算してから中央値を取りました。
2. 単回帰で効果量を読む
相関だけでは「前日が1%動いたら翌日は何%動くのか」が分かりにくいため、銘柄ごとに次の単回帰を行いました。前日の日中リターン $ID_t$ は、翌日のリターン $Y_{t+1}$ とどの程度関係するか?をみていきましょう。
$$
Y_{t+1} = \alpha + \beta ID_t + \varepsilon_{t+1}
$$
Y には翌日の夜間、日中、終値間リターンを順に入れます。
# 回帰係数でlag1を分解する
REGRESSION_MIN_OBS = 500
def fit_simple_regressions(x):
x = x.dropna(
subset=[
"ID",
"ON_next",
"ID_next",
"CC_next",
]
)
predictor = x["ID"].to_numpy()
predictor_mean = predictor.mean()
predictor_centered = predictor - predictor_mean
denominator = np.sum(
predictor_centered ** 2
)
if len(x) < REGRESSION_MIN_OBS or denominator == 0:
return pd.Series(dtype=float)
result = {
"Observations": len(x)
}
targets = {
"ON": "ON_next",
"ID": "ID_next",
"CC": "CC_next",
}
for label, target_column in targets.items():
target = x[target_column].to_numpy()
target_mean = target.mean()
beta = (
np.sum(
predictor_centered
* (target - target_mean)
)
/ denominator
)
alpha = (
target_mean
- beta * predictor_mean
)
fitted = alpha + beta * predictor
residual = target - fitted
total_variation = np.sum(
(target - target_mean) ** 2
)
residual_variation = np.sum(
residual ** 2
)
r_squared = (
1 - residual_variation / total_variation
if total_variation > 0
else np.nan
)
result[f"Alpha_{label}"] = alpha
result[f"Beta_{label}"] = beta
result[f"R2_{label}"] = r_squared
return pd.Series(result)
regression_by_stock = (
lag1_data
.groupby("YahooTicker", sort=False)[
[
"ID",
"ON_next",
"ID_next",
"CC_next",
]
]
.apply(fit_simple_regressions)
.dropna(subset=["Observations"])
.reset_index()
)
# 係数の加法関係を検算
regression_by_stock["BetaIdentityError"] = (
regression_by_stock["Beta_CC"]
- regression_by_stock["Beta_ON"]
- regression_by_stock["Beta_ID"]
).abs()
regression_by_stock["AlphaIdentityError"] = (
regression_by_stock["Alpha_CC"]
- regression_by_stock["Alpha_ON"]
- regression_by_stock["Alpha_ID"]
).abs()
regression_summary = pd.DataFrame([
{
"関係": "前日の日中 → 翌日の夜間",
"Beta中央値": regression_by_stock["Beta_ON"].median(),
"第1四分位": regression_by_stock["Beta_ON"].quantile(0.25),
"第3四分位": regression_by_stock["Beta_ON"].quantile(0.75),
"Betaマイナス割合": (
regression_by_stock["Beta_ON"] < 0
).mean(),
"Alpha中央値_bps": (
regression_by_stock["Alpha_ON"].median()
* 10_000
),
"R2中央値": regression_by_stock["R2_ON"].median(),
},
{
"関係": "前日の日中 → 翌日の日中",
"Beta中央値": regression_by_stock["Beta_ID"].median(),
"第1四分位": regression_by_stock["Beta_ID"].quantile(0.25),
"第3四分位": regression_by_stock["Beta_ID"].quantile(0.75),
"Betaマイナス割合": (
regression_by_stock["Beta_ID"] < 0
).mean(),
"Alpha中央値_bps": (
regression_by_stock["Alpha_ID"].median()
* 10_000
),
"R2中央値": regression_by_stock["R2_ID"].median(),
},
{
"関係": "前日の日中 → 翌日の終値間",
"Beta中央値": regression_by_stock["Beta_CC"].median(),
"第1四分位": regression_by_stock["Beta_CC"].quantile(0.25),
"第3四分位": regression_by_stock["Beta_CC"].quantile(0.75),
"Betaマイナス割合": (
regression_by_stock["Beta_CC"] < 0
).mean(),
"Alpha中央値_bps": (
regression_by_stock["Alpha_CC"].median()
* 10_000
),
"R2中央値": regression_by_stock["R2_CC"].median(),
},
])
display(
regression_summary.style.format({
"Beta中央値": "{:+.4f}",
"第1四分位": "{:+.4f}",
"第3四分位": "{:+.4f}",
"Betaマイナス割合": "{:.1%}",
"Alpha中央値_bps": "{:+.3f}",
"R2中央値": "{:.5f}",
})
)
validation = pd.DataFrame({
"項目": [
"回帰対象銘柄数",
"Beta加法関係の最大誤差",
"Alpha加法関係の最大誤差",
],
"値": [
len(regression_by_stock),
regression_by_stock[
"BetaIdentityError"
].max(),
regression_by_stock[
"AlphaIdentityError"
].max(),
],
})
display(validation)
regression_file = (
OUTPUT_DIR
/ "lag1_simple_regressions.csv"
)
regression_by_stock.to_csv(
regression_file,
index=False,
encoding="utf-8-sig",
)
print("保存先:", regression_file)
| 被説明変数 | Beta中央値 | 第1四分位 | 第3四分位 | Betaがマイナスの割合 | Alpha中央値 | R²中央値 |
|---|---|---|---|---|---|---|
| 翌日の夜間 | -0.0059 | -0.0290 | +0.0189 | 57.0% | +6.177 bps | 0.00089 |
| 翌日の日中 | -0.0232 | -0.0457 | -0.0014 | 76.3% | -1.949 bps | 0.00084 |
| 翌日の終値間 | -0.0293 | -0.0569 | +0.0016 | 74.1% | +4.222 bps | 0.00078 |
Betaをパーセントに読み替える
$ID_t → ID_{t+1}$ のBeta中央値は -0.0232 です。
つまり、前日の日中リターンが +1% = 0.01 なら、翌日の日中リターンへの寄与は
$$
-0.0232 \times 0.01 = -0.000232
$$
つまり -0.0232% = -2.32 bps です。
Betaが -0.0232だから翌日が -2.32%になる、という意味ではありません。リターンを小数で回帰しているため、入力する1%にも 0.01 を掛ける必要があります。
Alphaは「確実な利益」ではない
Alphaは、説明変数がゼロのときの条件付き平均です。
α: 前日の値動きで説明できない夜間プレミアム
たとえば翌日の夜間Alpha +6.177 bps は、「前日の日中リターンがゼロ付近のときの平均的な翌夜間リターン」を表します。
売買コスト、推定誤差、データの偏り、将来の構造変化を含まないため、直ちに投資戦略のアルファとは呼べません。
また、念の為補足をしておくと、R² = 0.00084 は、この単純な線形モデルが翌日リターンの分散の約 0.084% しか説明していないという意味です。符号が多くの銘柄で一致していても、日々の予測力が高いとは限りません。
金融リターンでは弱い効果が広く存在することはありますが、「存在する」と「取引できる」は別問題です。
3. 標準誤差をHACにし、多重検定をFDRで補正する
ここまでの中央値は、横断面の傾向を説明しています。しかし銘柄ごとに有意性を判定すると、別の問題が出ます。そのため、次の方法を使って補正をしました。
| 方法 | 防ぎたい問題 | 対象 |
|---|---|---|
| HAC標準誤差 | 時系列のクセによる、過小なp値 | 1銘柄の回帰の内部 |
| FDR補正 | 1,534銘柄を大量検定することによる偶然の当たり | 銘柄間の多重検定 |
from statsmodels.stats.multitest import multipletests
HAC_MAXLAGS = 5
FDR_LEVEL = 0.05
def fit_hac_regressions(x):
x = x.dropna(
subset=[
"ID",
"ON_next",
"ID_next",
"CC_next",
]
)
if len(x) < REGRESSION_MIN_OBS:
return pd.Series(dtype=float)
predictor = x["ID"].to_numpy()
design_matrix = sm.add_constant(
predictor,
has_constant="add",
)
result = {
"Observations": len(x)
}
targets = {
"ON": "ON_next",
"ID": "ID_next",
"CC": "CC_next",
}
for label, target_column in targets.items():
target = x[target_column].to_numpy()
fitted = sm.OLS(
target,
design_matrix,
).fit(
cov_type="HAC",
cov_kwds={
"maxlags": HAC_MAXLAGS
},
)
result[f"Alpha_{label}"] = fitted.params[0]
result[f"Beta_{label}"] = fitted.params[1]
result[f"BetaSE_{label}"] = fitted.bse[1]
result[f"BetaT_{label}"] = fitted.tvalues[1]
result[f"BetaP_{label}"] = fitted.pvalues[1]
return pd.Series(result)
hac_results = (
lag1_data
.groupby("YahooTicker", sort=False)[
[
"ID",
"ON_next",
"ID_next",
"CC_next",
]
]
.apply(fit_hac_regressions)
.dropna(subset=["Observations"])
.reset_index()
)
# Benjamini-Hochberg法によるFDR補正
for label in ["ON", "ID", "CC"]:
p_column = f"BetaP_{label}"
q_column = f"BetaQ_{label}"
reject_column = f"RejectFDR_{label}"
valid = hac_results[p_column].notna()
rejected, adjusted_p, _, _ = multipletests(
hac_results.loc[valid, p_column],
alpha=FDR_LEVEL,
method="fdr_bh",
)
hac_results[q_column] = np.nan
hac_results[reject_column] = False
hac_results.loc[
valid,
q_column
] = adjusted_p
hac_results.loc[
valid,
reject_column
] = rejected
# 結果の要約
summary_rows = []
relations = [
("前日の日中 → 翌日の夜間", "ON"),
("前日の日中 → 翌日の日中", "ID"),
("前日の日中 → 翌日の終値間", "CC"),
]
for relation_name, label in relations:
beta = hac_results[f"Beta_{label}"]
rejected = hac_results[f"RejectFDR_{label}"]
significant_negative = (
rejected & (beta < 0)
)
significant_positive = (
rejected & (beta > 0)
)
summary_rows.append({
"関係": relation_name,
"銘柄数": len(hac_results),
"Beta中央値": beta.median(),
"FDR有意・マイナス銘柄数": (
significant_negative.sum()
),
"FDR有意・マイナス割合": (
significant_negative.mean()
),
"FDR有意・プラス銘柄数": (
significant_positive.sum()
),
"FDR有意・プラス割合": (
significant_positive.mean()
),
"FDR有意でない割合": (
(~rejected).mean()
),
})
hac_summary = pd.DataFrame(
summary_rows
)
display(
hac_summary.style.format({
"Beta中央値": "{:+.4f}",
"FDR有意・マイナス割合": "{:.1%}",
"FDR有意・プラス割合": "{:.1%}",
"FDR有意でない割合": "{:.1%}",
})
)
# 保存
hac_file = (
OUTPUT_DIR
/ "lag1_hac_fdr_results.csv"
)
hac_results.to_csv(
hac_file,
index=False,
encoding="utf-8-sig",
)
print("HACの最大ラグ:", HAC_MAXLAGS)
print("FDR水準:", FDR_LEVEL)
print("保存先:", hac_file)
HAC標準誤差
通常のOLS標準誤差(回帰係数の推定値がどれくらいばらつくかという不確実性を示す指標)は、誤差の分散が一定で、時系列的に独立であることを強く仮定します。
いやなんか難しいので、もう少し丁寧に説明しましょう。
回帰式は次の形でしたね。
$$ Y_{t+1}=\alpha+\beta ID_t+\varepsilon_{t+1} $$
OLSでBetaを計算した後、「このBetaは偶然のノイズでも起こり得るか」を判断するために標準誤差を使います。
標準誤差が小さければ、
Betaはゼロから明確に離れている
と判定されやすくなります。
素朴なOLS標準誤差は、おおむね次を仮定します。
- 誤差の大きさが時期によって大きく変わらない
- 今日の誤差と明日の誤差が関係していない
一方で、株価リターンでは、この仮定が怪しくなります。
たとえば相場が荒れている時期には大きな値動きが何日も続きますよね。これは「ボラティリティ・クラスタリング」と呼ばれます。また、モデルで説明できなかった部分が翌日にも残る可能性があります。
そこでHACを使います。
HACは、
不均一分散(Heteroskedasticity)と自己相関(Autocorrelation)があっても、推測が大きく狂わないよう標準誤差を調整する方法
株式リターンではボラティリティの変化や自己相関があり得るため、本分析では maxlags=5 のHAC標準誤差を使いました。
maxlags=5 は、最大5営業日先までの誤差の関連を考慮する、という設定です。約1週間を意識した実務的な選択かと思います。
HACはBetaそのものを変更する方法ではありません。不均一分散と自己相関を考慮して、標準誤差・p値・信頼区間を計算し直す方法です。実装仕様はstatsmodels公式ドキュメントを参照してください。
import statsmodels.api as sm
x = sm.add_constant(stock_df[["ID"]])
y = stock_df["ID_next"]
fit = sm.OLS(y, x).fit(
cov_type="HAC",
cov_kwds={"maxlags": 5},
)
beta = fit.params["ID"]
pvalue = fit.pvalues["ID"]
なぜFDR補正が必要か
次に問題になるのが、1,534銘柄を同時に検定したことです。
1,534銘柄を有意水準5%で別々に検定すると、本当は効果がなくても偶然に小さなp値が出る銘柄が増えます。そこでBenjamini–Hochberg法によるFDR補正を使いました。
Benjamini–Hochberg法は、p値を小さい順に並べ、検定数を考慮して有意判定を厳しくします。
FDRは、一定の条件下で「有意と判定した集合に含まれる誤発見の期待割合」を抑える考え方です。
FDRはFalse Discovery Rate、つまり「誤発見率」。
簡略化していうと、
有意だと選んだ銘柄群の中に、偶然の当たりがどの程度混ざるか
を抑える方法です。有意になった各銘柄が95%の確率で正しい、という意味ではありません。
from statsmodels.stats.multitest import multipletests
result["FDR_significant"] = multipletests(
result["pvalue"],
alpha=0.05,
method="fdr_bh",
)[0]
FDR補正は3つの関係ごとに、1,534銘柄のp値をひとつの検定ファミリーとして適用しました。
| 関係 | FDR有意・マイナス | FDR有意・プラス | FDR有意でない割合 |
|---|---|---|---|
| 前日の日中 → 翌日の夜間 | 54銘柄(3.5%) | 11銘柄(0.7%) | 95.8% |
| 前日の日中 → 翌日の日中 | 27銘柄(1.8%) | 0銘柄(0.0%) | 98.2% |
| 前日の日中 → 翌日の終値間 | 41銘柄(2.7%) | 1銘柄(0.1%) | 97.3% |
横断面ではマイナス方向が多くても、個別銘柄の時系列から効果を高い確度で識別できたケースは少数でした。
これは矛盾ではありません。
- 「Betaがマイナスの銘柄が多い」:横断面で符号の偏りを見ている
- 「個別には有意でない」:各銘柄のBetaをノイズから区別できるか見ている
小さな負の効果が多くの銘柄に存在しても、銘柄ごとの推定誤差が大きければ両方が同時に成立します。
この方法が今回の結果をどう変えたのか?これがベストなのか?
「で、これをするとなにがいいんですか?」ってポンコツなことをおれ自信が聞いているので、一応説明しておきます。
たとえば、前日の日中から翌日の日中への経路では、
- Betaがマイナス:76.6%
- FDR有意かつマイナス:25銘柄
- FDR有意ではない:98.4%
でした。
したがって、慎重な結論は次のようになります。
東証プライム全体では負方向への偏りが見える。しかし、個別銘柄単位で安定した予測関係を統計的に確認できたケースは少ない。
HACとFDRを使わず、生のp値だけを並べていたら、「予測可能な銘柄を多数発見した」と過大評価した可能性があります。勘違い野郎にはなりたくないですからね。
これが最適な手段なのかっていうと、
「合理的な第一選択ではあるが、唯一の最適解ではない」が正確そうです(てか世の中だいたいそうだろ)。
今回のように、
- 銘柄ごとに時系列回帰する
- 日次データを扱う
- 誤差に不均一分散や自己相関がありそう
という条件では、HACは妥当な選択です。
ただし、maxlags=5 が最適だと証明されたわけではありません。1週間という解釈しやすい設定ですが、結果が設定に依存していないか、もっとちゃんとやるなら、
maxlags = 0, 1, 5, 10, 20
などでも確認すべきです。ぜひやってみて教えてください(他力本願)。
あ、あとHACは、各銘柄の時系列内部を扱いますが、
同じ日にトヨタとホンダが市場全体のニュースで一緒に動く
という銘柄間の相関は処理していません。
FDRについては、「1,534銘柄のうち、どの銘柄が個別に有意か」を探すなら、適切かと思われます。Bonferroni補正より検出力を残しやすく、探索的なクオンツ研究と相性がよい方法みたいです。
ただし、今回の中心的な問いが、
プライム市場全体に共通する傾向があるか
なら、個別銘柄を1,534回検定することだけが最適な方法ではないかもしれません。
今回については、そもそも私の中で、プライム市場全体に共通する傾向があるという仮定が想像つかなかったので、このような手段で進めております。
4. 夜間と日中を同時に入れたVAR(1)型の分解
単回帰では前日の日中しか使っていません。しかし、前日の夜間と日中が相関していれば、片方だけの回帰係数にはもう片方の情報も混ざります。
そこで次の2本を銘柄ごとに推定しました。
$$
ON_{t+1}
= \alpha_{ON}
$$
- $\beta_{ON\rightarrow ON}ON_t$
- $\beta_{ID\rightarrow ON}ID_t$
- $\varepsilon^{ON}_{t+1}$
$$
ID_{t+1}
= \alpha_{ID}
$$
- $\beta_{ON\rightarrow ID}ON_t$
- $\beta_{ID\rightarrow ID}ID_t$
- $\varepsilon^{ID}_{t+1}$
これは2変数のVAR(1)と同じ説明変数構造を持つ、2本のOLSです。ここでも銘柄別に推定し、HAC標準誤差とFDR補正を使いました。
ちなみに、$VAR(1)$ とは Vector Autoregression のことで、「一期前の複数の変数を使って、現在の複数の変数を同時に説明するモデル」です。
def fit_var1(stock_df):
cols = ["ON", "ID", "ON_next", "ID_next"]
d = stock_df[cols].dropna()
x = sm.add_constant(d[["ON", "ID"]])
fit_on = sm.OLS(d["ON_next"], x).fit(
cov_type="HAC", cov_kwds={"maxlags": 5}
)
fit_id = sm.OLS(d["ID_next"], x).fit(
cov_type="HAC", cov_kwds={"maxlags": 5}
)
return {
"beta_on_to_on": fit_on.params["ON"],
"beta_id_to_on": fit_on.params["ID"],
"beta_on_to_id": fit_id.params["ON"],
"beta_id_to_id": fit_id.params["ID"],
"alpha_on": fit_on.params["const"],
"alpha_id": fit_id.params["const"],
"r2_on": fit_on.rsquared,
"r2_id": fit_id.rsquared,
}
そして、最終形と結果はこちら↓
VAR_MIN_OBS = 500
VAR_HAC_MAXLAGS = 5
VAR_FDR_LEVEL = 0.05
def fit_var1_hac(x):
x = x.dropna(
subset=[
"ON",
"ID",
"ON_next",
"ID_next",
]
)
if len(x) < VAR_MIN_OBS:
return pd.Series(dtype=float)
# 説 aanvraag্রি変数:
# 0列目 = 定数
# 1列目 = 当日の
# 2列目 = 当
design_matrix = sm.add_constant(
x[["ON", "ID"]].to_numpy(),
has_constant="add",
)
result = {
"Observations": len(x)
}
targets = {
"ONnext": "ON_next",
"IDnext": "ID_next",
}
for target_label, target_column in targets.items():
target = x[target_column].to_numpy()
fitted = sm.OLS(
target,
design_matrix,
).fit(
cov_type="HAC",
cov_kwds={
"maxlags": VAR_HAC_MAXLAGS
},
)
result[f"Alpha_{target_label}"] = (
fitted.params[0]
)
result[f"Beta_ON_to_{target_label}"] = (
fitted.params[1]
)
result[f"Beta_ID_to_{target_label}"] = (
fitted.params[2]
)
result[f"P_ON_to_{target_label}"] = (
fitted.pvalues[1]
)
result[f"P_ID_to_{target_label}"] = (
fitted.pvalues[2]
)
result[f"R2_{target_label}"] = (
fitted.rsquared
)
return pd.Series(result)
var1_results = (
lag1_data
.groupby("YahooTicker", sort=False)[
[
"ON",
"ID",
"ON_next",
"ID_next",
]
]
.apply(fit_var1_hac)
.dropna(subset=["Observations"])
.reset_index()
)
# 4つの仮説を定義
var_relations = [
{
"name": "前日の夜間 → 翌日の夜間",
"beta": "Beta_ON_to_ONnext",
"p": "P_ON_to_ONnext",
},
{
"name": "前日の日中 → 翌日の夜間",
"beta": "Beta_ID_to_ONnext",
"p": "P_ID_to_ONnext",
},
{
"name": "前日の夜間 → 翌日の日中",
"beta": "Beta_ON_to_IDnext",
"p": "P_ON_to_IDnext",
},
{
"name": "前日の日中 → 翌日の日中",
"beta": "Beta_ID_to_IDnext",
"p": "P_ID_to_IDnext",
},
]
# 関係ごとにFDR補正
for relation in var_relations:
p_column = relation["p"]
beta_column = relation["beta"]
q_column = p_column.replace(
"P_",
"Q_",
1,
)
reject_column = (
"RejectFDR_"
+ beta_column.replace("Beta_", "")
)
valid = var1_results[p_column].notna()
rejected, adjusted_p, _, _ = multipletests(
var1_results.loc[valid, p_column],
alpha=VAR_FDR_LEVEL,
method="fdr_bh",
)
var1_results[q_column] = np.nan
var1_results[reject_column] = False
var1_results.loc[
valid,
q_column
] = adjusted_p
var1_results.loc[
valid,
reject_column
] = rejected
relation["q"] = q_column
relation["reject"] = reject_column
# 市場全体の要約
summary_rows = []
for relation in var_relations:
beta = var1_results[relation["beta"]]
rejected = var1_results[relation["reject"]]
significant_negative = (
rejected & (beta < 0)
)
significant_positive = (
rejected & (beta > 0)
)
summary_rows.append({
"関係": relation["name"],
"銘柄数": len(beta),
"Beta中央値": beta.median(),
"第1四分位": beta.quantile(0.25),
"第3四分位": beta.quantile(0.75),
"Betaマイナス割合": (
beta < 0
).mean(),
"FDR有意マイナス数": (
significant_negative.sum()
),
"FDR有意プラス数": (
significant_positive.sum()
),
"FDR有意でない割合": (
~rejected
).mean(),
})
var1_summary = pd.DataFrame(
summary_rows
)
display(
var1_summary.style.format({
"Beta中央値": "{:+.4f}",
"第1四分位": "{:+.4f}",
"第3四分位": "{:+.4f}",
"Betaマイナス割合": "{:.1%}",
"FDR有意でない割合": "{:.1%}",
})
)
# 切片とR²
target_summary = pd.DataFrame({
"翌日セッション": [
"翌日の夜間",
"翌日の日中",
],
"Alpha中央値_bps": [
var1_results["Alpha_ONnext"].median()
* 10_000,
var1_results["Alpha_IDnext"].median()
* 10_000,
],
"R2中央値": [
var1_results["R2_ONnext"].median(),
var1_results["R2_IDnext"].median(),
],
})
display(
target_summary.style.format({
"Alpha中央値_bps": "{:+.3f}",
"R2中央値": "{:.5f}",
})
)
# 保存
var1_file = (
OUTPUT_DIR
/ "session_var1_hac_fdr_results.csv"
)
var1_results.to_csv(
var1_file,
index=False,
encoding="utf-8-sig",
)
print("対象銘柄数:", len(var1_results))
print("保存先:", var1_file)
| 経路 | Beta中央値 | 第1四分位 | 第3四分位 | Betaがマイナスの割合 | FDR有意・マイナス | FDR有意・プラス |
|---|---|---|---|---|---|---|
| 夜間 → 翌日の夜間 | -0.0081 | -0.0278 | +0.0133 | 60.6% | 0 | 0 |
| 日中 → 翌日の夜間 | -0.0064 | -0.0288 | +0.0191 | 57.0% | 56 | 11 |
| 夜間 → 翌日の日中 | +0.0137 | -0.0168 | +0.0423 | 37.0% | 2 | 2 |
| 日中 → 翌日の日中 | -0.0236 | -0.0459 | -0.0016 | 76.6% | 25 | 0 |
翌日セッションの切片と決定係数は次の通りです。
| 翌日セッション | Alpha中央値 | R²中央値 |
|---|---|---|
| 翌日の夜間 | +6.196 bps | 0.00201 |
| 翌日の日中 | -1.940 bps | 0.00226 |
4経路を簡潔に読むと、次のようになります。
- $ID_t → ID_{t+1}$:小さな反転。4経路の中では最も方向がそろっている
- $ID_t → ON_{t+1}$:ごく小さな反転
- $ON_t → ID_{t+1}$:ごく小さな継続
- $ON_t → ON_{t+1}$:中央値はわずかに負だが、個別の有意性は確認できない
R²は約0.2%なので、このモデルは価格形成の「弱い平均構造」を記述しているのであって、翌日の騰落を高精度に当てているわけではありません。
終値間リターンとの加法関係
対数リターンでは $CC_{t+1} = ON_{t+1} + ID_{t+1}$ なので、同じ説明変数で回帰すれば、各銘柄について終値間回帰の係数は2本のセッション回帰の係数和になります。
実際の最大誤差は次の通りでした。
- Betaの加法関係:4.86 × 10^-16
- Alphaの加法関係:6.51 × 10^-18
ただし、表に載せた中央値同士は厳密には足し算できません。
$$
median(a_i+b_i) \neq median(a_i)+median(b_i)
$$
銘柄ごとの恒等式と、銘柄横断面の要約統計を区別する必要があります。
5. 期間を分けると、同じ構造は続いているか?
全期間の小さなBetaは、異なる時期の正負が打ち消し合った結果かもしれません。そこで2021年末までを前半、2022年以降を後半として分割してみました。
# 前半・後半の安定性
# 2022年1月1日を境に期間を分け、4つの係数が安定しているか確認します。
SPLIT_DATE = pd.Timestamp("2022-01-01")
MIN_PERIOD_OBS = 400
beta_columns = [
"Beta_ON_to_ONnext",
"Beta_ID_to_ONnext",
"Beta_ON_to_IDnext",
"Beta_ID_to_IDnext",
]
def calculate_period_results(data):
required_columns = [
"ON",
"ID",
"ON_next",
"ID_next",
]
clean_data = (
data.dropna(subset=required_columns)
.sort_values(["YahooTicker", "Date"])
.copy()
)
# 400観測以上ある銘柄だけ残す
observation_counts = (
clean_data
.groupby("YahooTicker")
.size()
)
eligible_tickers = observation_counts.loc[
observation_counts >= MIN_PERIOD_OBS
].index
clean_data = clean_data.loc[
clean_data["YahooTicker"].isin(
eligible_tickers
)
]
result_rows = []
for ticker, stock_data in clean_data.groupby(
"YahooTicker",
sort=False,
):
design_matrix = sm.add_constant(
stock_data[
["ON", "ID"]
].to_numpy(dtype=float),
has_constant="add",
)
target_on = (
stock_data["ON_next"]
.to_numpy(dtype=float)
)
target_id = (
stock_data["ID_next"]
.to_numpy(dtype=float)
)
coefficients_on = np.linalg.lstsq(
design_matrix,
target_on,
rcond=None,
)[0]
coefficients_id = np.linalg.lstsq(
design_matrix,
target_id,
rcond=None,
)[0]
result_rows.append({
"YahooTicker": ticker,
"Observations": len(stock_data),
"Alpha_ONnext":
coefficients_on[0],
"Beta_ON_to_ONnext":
coefficients_on[1],
"Beta_ID_to_ONnext":
coefficients_on[2],
"Alpha_IDnext":
coefficients_id[0],
"Beta_ON_to_IDnext":
coefficients_id[1],
"Beta_ID_to_IDnext":
coefficients_id[2],
})
return pd.DataFrame(result_rows)
# 前半:2021年12月31日まで
early_results = calculate_period_results(
lag1_data.loc[
lag1_data["Date"] < SPLIT_DATE
]
)
# 後半:2022年1月1日以降
late_results = calculate_period_results(
lag1_data.loc[
lag1_data["Date"] >= SPLIT_DATE
]
)
print("前半で計算できた銘柄数:", len(early_results))
print("後半で計算できた銘柄数:", len(late_results))
if early_results.empty:
raise ValueError(
"前半の計算結果が空です"
)
if late_results.empty:
raise ValueError(
"後半の計算結果が空です"
)
# 前半・後半を区別する接尾辞を付ける
early_results = early_results.rename(
columns={
column: f"{column}_early"
for column in early_results.columns
if column != "YahooTicker"
}
)
late_results = late_results.rename(
columns={
column: f"{column}_late"
for column in late_results.columns
if column != "YahooTicker"
}
)
# 両期間で計算できた銘柄だけ比較
stability_results = early_results.merge(
late_results,
on="YahooTicker",
how="inner",
)
relation_labels = {
"Beta_ON_to_ONnext":
"夜間 → 翌日の夜間",
"Beta_ID_to_ONnext":
"日中 → 翌日の夜間",
"Beta_ON_to_IDnext":
"夜間 → 翌日の日中",
"Beta_ID_to_IDnext":
"日中 → 翌日の日中",
}
stability_rows = []
for beta_column, relation_name in relation_labels.items():
early_beta = stability_results[
f"{beta_column}_early"
]
late_beta = stability_results[
f"{beta_column}_late"
]
same_sign = (
np.sign(early_beta)
== np.sign(late_beta)
)
stability_rows.append({
"関係": relation_name,
"共通銘柄数": len(stability_results),
"前半Beta中央値":
early_beta.median(),
"後半Beta中央値":
late_beta.median(),
"前半マイナス割合":
(early_beta < 0).mean(),
"後半マイナス割合":
(late_beta < 0).mean(),
"符号一致割合":
same_sign.mean(),
"銘柄別Betaの前後相関":
early_beta.corr(late_beta),
})
stability_summary = pd.DataFrame(
stability_rows
)
display(
stability_summary.style.format({
"前半Beta中央値": "{:+.4f}",
"後半Beta中央値": "{:+.4f}",
"前半マイナス割合": "{:.1%}",
"後半マイナス割合": "{:.1%}",
"符号一致割合": "{:.1%}",
"銘柄別Betaの前後相関": "{:+.4f}",
})
)
# 保存
stability_file = (
OUTPUT_DIR
/ "var1_early_late_stability.csv"
)
stability_results.to_csv(
stability_file,
index=False,
encoding="utf-8-sig",
)
print()
print(
"前半終了:",
SPLIT_DATE - pd.Timedelta(days=1)
)
print("後半開始:", SPLIT_DATE)
print(
"前後両方を計算できた銘柄数:",
len(stability_results)
)
print("保存先:", stability_file)
| 経路 | 共通銘柄数 | 前半Beta中央値 | 後半Beta中央値 | 前半マイナス割合 | 後半マイナス割合 | 符号一致割合 | 銘柄別Betaの前後相関 |
|---|---|---|---|---|---|---|---|
| 夜間 → 翌日の夜間 | 1,481 | +0.0228 | -0.0500 | 26.8% | 83.9% | 33.2% | -0.0016 |
| 日中 → 翌日の夜間 | 1,481 | +0.0112 | -0.0305 | 41.3% | 78.5% | 48.8% | +0.1859 |
| 夜間 → 翌日の日中 | 1,481 | +0.0128 | +0.0131 | 41.8% | 40.2% | 57.8% | +0.1280 |
| 日中 → 翌日の日中 | 1,481 | -0.0323 | -0.0154 | 77.3% | 66.0% | 60.8% | +0.0997 |
翌日の夜間を目的変数にした2経路は、前半と後半で中央値の符号が逆転しました。特に $ON_t → ON_{t+1}$ は符号一致が33.2%、銘柄別Betaの前後相関もほぼゼロです。
一方、$ID_t → ID_{t+1}$ は両期間とも負で、今回の4経路では相対的に安定していました。それでもBetaの大きさは前半 -0.0323 から後半 -0.0154 へ変化しています。
この結果からいえるのは「2022年に構造変化が起きた可能性がある」までです。分割点をデータ確認後に選んでいるため、東証の市場区分再編など特定の出来事が原因だとは証明できません。構造変化を正式に主張するには、事前に定めた分割、複数候補日の検定、ローリング推定などが必要です。
6. 10%超の極端な値動きを除いても残るか
株式分割、上場直後、ストップ高・安、データ補正の問題など、少数の極端値がOLSを大きく動かす可能性があります。
感応度分析として、$ON_t、ID_t、ON_{t+1}、ID_{t+1}$ のいずれかを単純リターンへ戻した値が絶対値10%を超える行を除外し、同じVAR型回帰を再推定しました。
ret_cols = ["ON", "ID", "ON_next", "ID_next"]
# 対数リターンを単純リターンへ戻して10%を判定
extreme = np.column_stack([
np.abs(np.expm1(lag_df[c].to_numpy())) > 0.10
for c in ret_cols
]).any(axis=1)
robust_df = lag_df.loc[~extreme].copy()
最終形態と結果はこちら↓
ROBUST_MIN_OBS = 500
EXTREME_THRESHOLD = 0.10
return_columns = [
"ON",
"ID",
"ON_next",
"ID_next",
]
# 対数リターンを通常の騰落率へ戻してから
# 絶対値10%超を判定
arithmetic_returns = np.expm1(
lag1_data[return_columns]
)
extreme_row = (
arithmetic_returns.abs()
> EXTREME_THRESHOLD
).any(axis=1)
robust_data = (
lag1_data.loc[~extreme_row]
.copy()
)
removed_rows = int(extreme_row.sum())
removed_ratio = extreme_row.mean()
print("元の行数:", len(lag1_data))
print("除外行数:", removed_rows)
print(f"除外割合: {removed_ratio:.3%}")
print("除外後行数:", len(robust_data))
def calculate_robust_results(data):
clean_data = (
data.dropna(
subset=[
"ON",
"ID",
"ON_next",
"ID_next",
]
)
.sort_values(
["YahooTicker", "Date"]
)
)
counts = (
clean_data
.groupby("YahooTicker")
.size()
)
eligible = counts.loc[
counts >= ROBUST_MIN_OBS
].index
clean_data = clean_data.loc[
clean_data["YahooTicker"].isin(
eligible
)
]
rows = []
for ticker, stock_data in clean_data.groupby(
"YahooTicker",
sort=False,
):
design_matrix = sm.add_constant(
stock_data[
["ON", "ID"]
].to_numpy(dtype=float),
has_constant="add",
)
target_on = stock_data[
"ON_next"
].to_numpy(dtype=float)
target_id = stock_data[
"ID_next"
].to_numpy(dtype=float)
coefficients_on = np.linalg.lstsq(
design_matrix,
target_on,
rcond=None,
)[0]
coefficients_id = np.linalg.lstsq(
design_matrix,
target_id,
rcond=None,
)[0]
rows.append({
"YahooTicker": ticker,
"Observations": len(stock_data),
"Alpha_ONnext":
coefficients_on[0],
"Beta_ON_to_ONnext":
coefficients_on[1],
"Beta_ID_to_ONnext":
coefficients_on[2],
"Alpha_IDnext":
coefficients_id[0],
"Beta_ON_to_IDnext":
coefficients_id[1],
"Beta_ID_to_IDnext":
coefficients_id[2],
})
return pd.DataFrame(rows)
robust_results = calculate_robust_results(
robust_data
)
# 元の推定値と結合
comparison_columns = [
"YahooTicker",
"Alpha_ONnext",
"Alpha_IDnext",
"Beta_ON_to_ONnext",
"Beta_ID_to_ONnext",
"Beta_ON_to_IDnext",
"Beta_ID_to_IDnext",
]
robust_comparison = (
var1_results[comparison_columns]
.merge(
robust_results[comparison_columns],
on="YahooTicker",
how="inner",
suffixes=("_raw", "_robust"),
)
)
robustness_rows = []
for beta_column, relation_name in relation_labels.items():
raw_beta = robust_comparison[
f"{beta_column}_raw"
]
robust_beta = robust_comparison[
f"{beta_column}_robust"
]
same_sign = (
np.sign(raw_beta)
== np.sign(robust_beta)
)
robustness_rows.append({
"関係": relation_name,
"比較銘柄数": len(raw_beta),
"元Beta中央値":
raw_beta.median(),
"10%超除外後Beta中央値":
robust_beta.median(),
"元Betaマイナス割合":
(raw_beta < 0).mean(),
"除外後Betaマイナス割合":
(robust_beta < 0).mean(),
"符号一致割合":
same_sign.mean(),
"元と除外後Betaの相関":
raw_beta.corr(robust_beta),
})
robustness_summary = pd.DataFrame(
robustness_rows
)
display(
robustness_summary.style.format({
"元Beta中央値": "{:+.4f}",
"10%超除外後Beta中央値": "{:+.4f}",
"元Betaマイナス割合": "{:.1%}",
"除外後Betaマイナス割合": "{:.1%}",
"符号一致割合": "{:.1%}",
"元と除外後Betaの相関": "{:+.4f}",
})
)
# 切片=lag調整後のセッションプレミアムも比較
alpha_summary = pd.DataFrame({
"セッション": [
"翌日の夜間",
"翌日の日中",
],
"元Alpha中央値_bps": [
robust_comparison[
"Alpha_ONnext_raw"
].median() * 10_000,
robust_comparison[
"Alpha_IDnext_raw"
].median() * 10_000,
],
"10%超除外後Alpha中央値_bps": [
robust_comparison[
"Alpha_ONnext_robust"
].median() * 10_000,
robust_comparison[
"Alpha_IDnext_robust"
].median() * 10_000,
],
})
display(
alpha_summary.style.format({
"元Alpha中央値_bps": "{:+.3f}",
"10%超除外後Alpha中央値_bps": "{:+.3f}",
})
)
# 保存
robust_file = (
OUTPUT_DIR
/ "var1_extreme_return_robustness.csv"
)
robust_comparison.to_csv(
robust_file,
index=False,
encoding="utf-8-sig",
)
print("比較銘柄数:", len(robust_comparison))
print("保存先:", robust_file)
| 項目 | 値 |
|---|---|
| 元の行数 | 3,583,533 |
| 除外行数 | 22,470 |
| 除外割合 | 0.627% |
| 除外後行数 | 3,561,063 |
| 経路 | 元Beta中央値 | 除外後Beta中央値 | 元のマイナス割合 | 除外後のマイナス割合 | 銘柄別符号一致 | Beta前後相関 |
|---|---|---|---|---|---|---|
| 夜間 → 翌日の夜間 | -0.0081 | -0.0056 | 60.6% | 56.9% | 73.2% | +0.4511 |
| 日中 → 翌日の夜間 | -0.0064 | -0.0056 | 57.0% | 57.2% | 85.8% | +0.7673 |
| 夜間 → 翌日の日中 | +0.0137 | +0.0082 | 37.0% | 41.7% | 82.1% | +0.7700 |
| 日中 → 翌日の日中 | -0.0236 | -0.0225 | 76.66% | 77.1% | 91.3% | +0.8883 |
$ID_t → ID_{t+1}$ は、除外後も中央値・負の銘柄割合ともにほぼ維持されました。反対に $ON_t → ON_{t+1}$ は4経路の中で極端値の影響を受けやすい結果でした。
これは「10%超を除くのが正しい」という意味ではありません。閾値は恣意的であり、極端な価格形成も市場の一部です。ここではデータを都合よくきれいにするためではなく、結論が少数観測に依存していないかを見る感応度分析として使っています。
セッション別の切片は残った
| 翌日セッション | 元Alpha中央値 | 10%超除外後 |
|---|---|---|
| 翌日の夜間 | +6.196 bps | +5.602 bps |
| 翌日の日中 | -1.940 bps | -2.229 bps |
前日の夜間・日中リターンを説明変数に入れ、さらに極端値を除いても、典型的な銘柄では翌日の夜間Alphaがプラス、日中Alphaがマイナスでした。
前回観測したセッション別の偏りは、少なくとも単純なlag1と少数の10%超変動だけでは説明し切れない、という示唆です。ただし、ここでのAlphaも因果的なリスクプレミアムを証明するものではありません。
今回見えた価格形成の地図
| 現象 | 今回の評価 | 理由 |
|---|---|---|
| 日中 → 翌日の日中の反転 | 4経路では最も頑健 | 符号が広く負で、期間分割・極端値除外後も残る |
| 日中 → 翌日の夜間の反転 | 弱く、時期依存 | 全期間では小さな負、前後半で符号が変化 |
| 夜間 → 翌日の日中の継続 | ごく弱い | 中央値は正だが個別有意はほとんどない |
| 夜間 → 翌日の夜間の反転 | 不安定 | 期間差・極端値の影響が大きい |
| 翌日の夜間Alpha | 正の水準が残る | lag1と極端値を調整しても中央値はプラス |
| 翌日の日中Alpha | 負の水準が残る | 同じ条件で中央値はマイナス |
最も重要なのは、セッションの平均的な水準差と、前日の値動きから翌日へ伝わるlag1係数を分けて考えることです。
- SessionTilt:長期的にどのセッションへリターンが偏っているか
- lag1 Beta:今日の変動が翌日の各セッションへどの方向・大きさで伝わるか
- Alpha:この線形lag1で説明されなかった条件付き平均
似て見えますが、それぞれ答えている問いが違います。
この分析から売買ルールを作ってよいか?
現時点では「いいえ」だと思います。
理由は明確です。
- R²が極めて低く、日々の予測誤差が大きい
- 大半の銘柄はFDR補正後に有意ではない
- 一部の経路は期間によって符号まで変わる
- 現在の銘柄集合を過去へ延ばしており、生存者バイアスがある
- 売買コスト、寄り付きのスリッページ、空売り制約を検証していない
- 同じデータを見ながら仮説を発展させており、完全なアウト・オブ・サンプルではない
こうした株研究では、面白い統計パターンを見つけた段階と、実際に取引できると判断する段階の間に大きな距離があります。これは実践していればなんとなく感覚的にわかるようなことですよね。
次のネタ案
もし続けるならば、こんなことができるのではないか、というアイデアです。
- 市場・業種中立化:各セッションの市場リターンを引いてからlag1を再推定する
- 流動性別の層別化:売買代金、時価総額、スプレッドでBetaを比較する
- ローリング推定:固定的な前後半ではなく、係数の時間変化を連続的に見る
- point-in-time銘柄集合:各時点の上場銘柄を復元し、生存者バイアスを減らす
- 事前登録した検証期間:仮説生成と最終検証のデータを完全に分ける
- ポートフォリオ検証:予測値で分位ポートフォリオを組み、コスト・制約込みで評価する
特に次の一歩として重要なのは、市場全体の夜間・日中リターンを除いた残差で同じ分析をすることです。それでも $ID_t → ID_{t+1}$ の反転が残るなら、より銘柄固有の価格形成に近づきます。
まとめ
今回は、東証プライム1,534銘柄を対象に、夜間・日中リターンの4つのlag1経路を分解しました。
- 日中から翌日の日中には、小さいものの広い反転傾向があった
- 日々のリターンを説明する力は非常に弱かった
- 個別銘柄で統計的に識別できるケースは少なかった
- 翌日の夜間へつながる経路は、期間によって大きく変化した
- 極端値を除いても、夜間プラス・日中マイナスのセッション別Alphaは残った
今回の収穫は、すぐに使える株売買ルールではありません。参考にはなってほしいですが!
むしろ、前回見つけたSessionTiltを
- セッション固有の平均
- 前日から翌日へ伝わるlag1
- 極端値やレジームによる変化
へ分けて考えられたことです。
クオンツ分析では、ひとつの数字を発見して終わるのではなく、「何がその数字を作っているのか」を少しずつ分解することが大事と心得ています。今回のlag1分析は、その過程を体験するための良い題材になりました。ありがとうございました。
お疲れ様でした。
わからないところ、間違っているところ、もっといい方法がある場合は、コメントでもDMでも教えてください。