pandasで株価データを整形|静かに壊れる操作と対策

yfinanceのデータを自在に整形する Python実装・コード

※本記事には広告・アフィリエイトリンクが含まれます。収益はサイト運営・検証費用に充てています。

結論から言うと:

  • 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する習慣をつけると、静かな事故にかなり気づけるようになります。

おすすめ書籍まとめはこちら


【投資免責事項】本記事で紹介するコードおよび分析結果は情報提供を目的としたものであり、特定の銘柄への投資を推奨するものではありません。投資判断はご自身の責任で行ってください。

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