※本記事には広告・アフィリエイトリンクが含まれます。収益はサイト運営・検証費用に充てています。
結論から言うと:
- yfinanceのデータはそのままでは扱いにくく、pandasの整形が必須です
- ただし一般的なpandas入門どおりに書くと、金融データでは静かに壊れる操作がいくつもあります
- 特に危険なのが欠損の前方補完、終値からのOHLC生成、複数銘柄のdropnaの3つです
この記事では基本操作を押さえつつ、その裏にある落とし穴を潰していきます。どれも自分が実際にやらかしたものです。
データ構造を確認する
import numpy as np
import pandas as pd
import yfinance as yf
df = yf.download("7203.T", period="1y", auto_adjust=True, progress=False)
print(df.columns) # 1銘柄でもMultiIndexで返る
print(df.index.tz) # download は tz-naive、Ticker.history は tz-aware
def flatten(df: pd.DataFrame) -> pd.DataFrame:
if isinstance(df.columns, pd.MultiIndex):
df = df.copy()
df.columns = df.columns.get_level_values(0)
return df
df = flatten(df)
最初に必ず確認するのがこの2点です。列がMultiIndexかどうかとインデックスにタイムゾーンが付いているか。yf.download()とTicker.history()で挙動が違うため、両方を混ぜると結合時にTypeErrorになります。
落とし穴1:価格の前方補完は嘘のデータを作る
入門記事では欠損処理としてffill()が紹介されます。しかし株価にこれをやると、存在しない「値動きゼロの日」が生まれます。
def ffill_damage(close: pd.Series, n_gaps: int = 20,
seed: int = 0) -> None:
"""欠損をffillしたときにボラティリティがどう歪むか。"""
rng = np.random.default_rng(seed)
holed = close.copy()
holed.iloc[rng.choice(len(close), n_gaps, replace=False)] = np.nan
filled = holed.ffill()
dropped = holed.dropna()
for name, s in [("元データ", close), ("ffill", filled),
("dropna", dropped)]:
r = s.pct_change().dropna()
print(f"{name:8s} 年率ボラ={r.std() * np.sqrt(252) * 100:5.2f}% "
f"変化ゼロの日={int((r == 0).sum()):3d}日")
ffill_damage(df["Close"])
ffillしたデータは、年率ボラティリティが実際より低く出ます。リターンがゼロの日が水増しされるからです。VaRやポジションサイズをこの値から計算すると、リスクを過小評価します。
さらに厄介なのが相関です。2銘柄をそれぞれ別の日でffillすると、片方だけ動かない日ができて相関が実際より低く見えます。分散投資の効果を過大評価する原因になります。
| 状況 | 正しい対処 |
|---|---|
| 数日の欠損(取得漏れ) | dropna()で落とす |
| 売買停止・上場前 | その期間ごと除外する |
| 市場休日の差(日本と米国) | 共通営業日にinner join |
| 財務データ・指数など低頻度 | ffillしてよい(値が続く性質) |
ffillが正しいのは「前の値が続いていると考えるのが自然なデータ」だけです。PERやEPS、金利水準はそれに当たります。株価は当たりません。
なお、fillna(method='ffill')という書き方は非推奨になりました。df.ffill()を直接呼んでください。
落とし穴2:終値からOHLCを作ってはいけない
月次のOHLCを作るとき、こう書いてある記事をよく見ます。
# ❌ これは「終値の」OHLCであって、月足のOHLCではない
monthly_wrong = df["Close"].resample("ME").ohlc()
これで得られるhighはその月の終値の最大値です。本来の月足の高値は日中高値の最大値なので、必ず低く出ます。安値も同様に高く出ます。
# ✅ 正しい集約
AGG = {"Open": "first", "High": "max", "Low": "min",
"Close": "last", "Volume": "sum"}
monthly = df.resample("ME").agg(AGG).dropna()
weekly = df.resample("W-FRI").agg(AGG).dropna() # 週の終わりは金曜
wrong = df["Close"].resample("ME").ohlc()
cmp = pd.DataFrame({
"正しい高値": monthly["High"],
"終値ベース": wrong["high"],
})
cmp["差%"] = (cmp["正しい高値"] / cmp["終値ベース"] - 1) * 100
print(cmp.tail(6).round(2))
実際に比べると、月足の高値が2〜5%も違うことがあります。この差でブレイクアウトの判定が変わります。週足・月足でバックテストしている人は確認してみてください。
週次でresample("W")と書くと、既定では日曜日のラベルが付きます。金曜に終わった週が「日曜日のデータ」として扱われるので、日足と結合するときにずれます。"W-FRI"を指定してください。
落とし穴3:複数銘柄のdropnaはサンプルを溶かす
TICKERS = ["7203.T", "6758.T", "9984.T", "8306.T", "6501.T"]
wide = yf.download(TICKERS, period="10y", auto_adjust=True,
progress=False)["Close"]
print(f"全体 : {len(wide)}行")
print(f"dropna後 : {len(wide.dropna())}行")
print("銘柄別の欠損:\n", wide.isna().sum())
dropna()は「どれか1銘柄でも欠けている日」を全部落とします。1銘柄が上場して日が浅いだけで、他の全銘柄の10年ぶんのデータが消えます。しかも静かに消えるので気づきません。
def align_report(wide: pd.DataFrame, min_ratio: float = 0.9) -> pd.DataFrame:
"""欠損の少ない銘柄だけ残してから揃える。"""
ratio = wide.notna().mean()
keep = ratio[ratio >= min_ratio].index.tolist()
dropped = [c for c in wide.columns if c not in keep]
if dropped:
print(f"除外(欠損が多い): {dropped}")
out = wide[keep].dropna()
print(f"{len(wide)}行 → {len(out)}行 銘柄 {len(wide.columns)} → {len(keep)}")
return out
aligned = align_report(wide)
相関行列を作るときは、もう一段の注意が必要です。DataFrame.corr()はペアごとに欠損を除外するため、ペアによって計算に使った期間が違います。できあがった行列が数学的に不整合(半正定値でない)になり、ポートフォリオ最適化が変な解を返します。
r = wide.pct_change()
print("ペアワイズ:", r.corr().shape, "← 期間がバラバラ")
print("期間を揃える:", r.dropna().corr().shape, "← こちらを使う")
落とし穴4:pct_changeは穴を飛び越える
dropna()で行を落としたあとにpct_change()を呼ぶと、離れた2日をつないだ架空のリターンが生まれます。売買停止が1ヶ月あった銘柄では、その前後が1日のリターンとして計算されます。
def safe_returns(close: pd.Series, max_gap_days: int = 7) -> pd.Series:
"""日付が離れすぎている箇所のリターンを無効化する。"""
r = close.pct_change()
gap = close.index.to_series().diff().dt.days
return r.mask(gap > max_gap_days)
r_raw = df["Close"].pct_change()
r_safe = safe_returns(df["Close"])
print(f"無効化した日数: {int(r_raw.notna().sum() - r_safe.notna().sum())}")
print(f"最大リターン: {r_raw.max() * 100:.2f}% → {r_safe.max() * 100:.2f}%")
連休で数日空くのは正常なので、7日程度を閾値にしておくと年末年始やゴールデンウィークを誤って落としません。
ワイド形式とロング形式を使い分ける
複数銘柄を扱うとき、列名を'_'.join()で潰す方法が紹介されますが、あとの処理が書きにくくなります。銘柄ごとの計算をするならロング形式(縦持ち)に変換するのが圧倒的に楽です。
long = (wide.stack()
.rename("close")
.rename_axis(["date", "ticker"])
.reset_index())
# 銘柄ごとの指標を1行で計算できる
g = long.sort_values("date").groupby("ticker", group_keys=False)
long["ret"] = g["close"].pct_change()
long["ma20"] = g["close"].transform(lambda s: s.rolling(20).mean())
long["vol20"] = g["ret"].transform(lambda s: s.rolling(20).std())
long["rank"] = long.groupby("date")["ret"].rank(pct=True) # 日次の順位
print(long.tail())
# ワイドに戻す
back = long.pivot(index="date", columns="ticker", values="close")
groupby("date")で日次の横断的な順位を出せるのがロング形式の強みです。クロスセクション戦略(その日の上位銘柄を買う)を書くならこの形一択です。ワイド形式のままだと.rank(axis=1)で書けなくはないものの、条件が増えると破綻します。
日本株と米国株を安全に結合する
def join_markets(jp: pd.Series, us: pd.Series) -> pd.DataFrame:
"""米国市場の終値は日本の翌営業日に反映される。ラグを明示する。"""
jp = jp.copy(); us = us.copy()
for s in (jp, us):
if s.index.tz is not None:
s.index = s.index.tz_localize(None)
out = pd.DataFrame({"jp": jp})
# 米国のt日終値は日本のt+1日にしか使えない
out["us_lag1"] = us.reindex(out.index, method="ffill").shift(1)
return out.dropna()
jp = flatten(yf.download("7203.T", period="3y", auto_adjust=True,
progress=False))["Close"]
us = flatten(yf.download("^SOX", period="3y", auto_adjust=True,
progress=False))["Close"]
merged = join_markets(jp, us)
print(merged.tail())
ここで.shift(1)を入れないと未来参照になります。米国市場が閉まるのは日本時間の翌朝です。同じ日付でそのまま結合すると、「まだ起きていない米国の値動き」を使って日本株を予測することになります。
この種のミスはバックテストの成績を劇的に良くするため、結果が良すぎたら真っ先に疑うべき箇所です。
SettingWithCopyWarningを消す
# ❌ 代入が反映されないことがある
sub = df[df["Volume"] > 0]
sub["ret"] = sub["Close"].pct_change() # SettingWithCopyWarning
# ✅ 明示的にコピーする
sub = df[df["Volume"] > 0].copy()
sub["ret"] = sub["Close"].pct_change()
# ✅ あるいは代入せずに新しい列を返す
sub = df.loc[df["Volume"] > 0].assign(ret=lambda d: d["Close"].pct_change())
警告を無視して進むと、代入したはずの列が消えているという事故が起きます。.copy()か.assign()を癖にしてください。
その他の基本操作
close = df["Close"]
# 変化率と差分
pct = close.pct_change()
diff = close.diff()
# 累積リターン(対数のほうが加法的で扱いやすい)
cum = (1 + pct).cumprod() - 1
log_cum = np.log1p(pct).cumsum()
# 期間フィルタ
q1 = df.loc["2025-01-01":"2025-03-31"]
march = df[(df.index.year == 2025) & (df.index.month == 3)]
# 曜日別の平均リターン
by_dow = pct.groupby(pct.index.dayofweek).agg(["count", "mean"])
by_dow.index = ["月", "火", "水", "木", "金"][:len(by_dow)]
print((by_dow["mean"] * 100).round(4))
# ドローダウン
eq = (1 + pct.fillna(0)).cumprod()
dd = eq / eq.cummax() - 1
print(f"最大ドローダウン: {dd.min() * 100:.2f}%")
曜日別リターンのような集計は簡単に出せますが、ここで差が見えても喜ばないでください。5グループに分ければ、乱数でも1つは目立ちます。判断するなら信頼区間まで見る必要があります。
非推奨になった書き方
| 古い書き方 | 現在の書き方 |
|---|---|
resample('M') | resample('ME') |
resample('Q') | resample('QE') |
resample('Y') | resample('YE') |
fillna(method='ffill') | ffill() |
df.append(other) | pd.concat([df, other]) |
df.iteritems() | df.items() |
FutureWarningを放置すると、pandasのメジャーアップデートで一斉に動かなくなります。警告が出た時点で直すのがいちばん安いです。
まとめ
- 価格の欠損に
ffillを使わない。ボラティリティと相関が歪む - 週足・月足のOHLCは
aggで正しく集約する。終値からは作れない - 週次は
W-FRIを指定する。既定は日曜ラベル - 複数銘柄の
dropna()はサンプルを大量に消す。前後の行数を必ず確認 - 相関行列は期間を揃えてから計算する
- 銘柄横断の計算はロング形式にすると簡単になる
- 市場をまたぐ結合は
shift(1)で未来参照を防ぐ
pandasの操作そのものは難しくありません。難しいのは「その操作が金融データにとって意味を持つか」の判断です。行数と統計量を毎回printする習慣をつけると、静かな事故にかなり気づけるようになります。
【投資免責事項】本記事で紹介するコードおよび分析結果は情報提供を目的としたものであり、特定の銘柄への投資を推奨するものではありません。投資判断はご自身の責任で行ってください。

