最大ドローダウンをPythonで計算|MDDでポジション量を決める

Python実装・コード

キオクシアが11%安、アドバンテストも大幅安。証券アプリを開いた瞬間、含み損の数字を二度見してしまいました。僕は半導体関連の製造メーカー株をそこそこ持っているので、久しぶりに背筋が凍る朝でした。そこでふと「自分のポートフォリオの最大ドローダウンって、これまでどれくらいだったんだろう」と気になり、Pythonで計算してみることにしました。

正直に言うと、僕は今まで「含み損がどれくらいまで耐えられるか」を感覚でしか把握していませんでした。株価が下がるたびに「まあこんなもんか」「いやこれはヤバいかも」と気分で判断していたんです。でもそれって、後から見返すと再現性がないんですよね。

この記事では、個別銘柄のMDDを出すところから始めて、ポートフォリオ単位のMDD回復までにかかった日数、そしてMDDから逆算するポジションサイズまで書きます。前回「次はポートフォリオ単位で出す」と言って終わっていたので、その続きです。

最大ドローダウン(Max Drawdown)とは

最大ドローダウン(Maximum Drawdown, MDD)は、資産曲線がそれまでの最高値からどれだけ下落したか、その最大幅を表す指標です。バックテストの世界ではリターンと並んでほぼ必ずチェックされる数字で、「その戦略、どれだけ痛い目に遭う可能性があるか」を教えてくれます。

計算式はシンプルで、ある時点 t での資産額を Equity(t)、それまでの累積最高値を Peak(t) = max(Equity(0…t)) とすると、

Drawdown(t) = (Equity(t) − Peak(t)) / Peak(t)

この Drawdown(t) の全期間における最小値(つまり一番マイナスが大きい値)が最大ドローダウンです。年率リターンが良くても、最大ドローダウンが40%とか50%とかある戦略は、実際に運用中にその下落を食らったらメンタルが持たない可能性が高いです。僕がまさにそのタイプでした。

MDD元本回復に必要な上昇率体感
-10%+11.1%まだ冷静でいられる
-20%+25.0%アプリを開く回数が増える
-30%+42.9%夜眠れなくなる
-50%+100%ほぼ確実に途中で投げる

この表は覚えておく価値があります。半値になったら倍にしないと戻らない。MDDが「もう一方の顔」を持っているのはここで、下落率と回復率は対称ではありません。

Pythonで計算してみる

pandasがあれば数行で書けます。yfinanceで日本株の株価を取得して、そのまま最大ドローダウンを出してみました。

import numpy as np
import pandas as pd
import yfinance as yf

def load_close(ticker: str, period: str = "3y") -> pd.Series:
    df = yf.download(ticker, period=period, auto_adjust=True, progress=False)
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)
    s = df["Close"].dropna()
    s.index = pd.to_datetime(s.index).tz_localize(None)
    return s

def max_drawdown(equity: pd.Series) -> dict:
    running_max = equity.cummax()
    drawdown = (equity - running_max) / running_max
    mdd = float(drawdown.min())
    trough = drawdown.idxmin()
    peak = equity.loc[:trough].idxmax()
    return {
        "mdd": round(mdd, 4),
        "peak_date": peak.date(),
        "trough_date": trough.date(),
        "recovery_needed": round(1 / (1 + mdd) - 1, 4),
    }

close = load_close("6857.T")   # アドバンテスト
print(max_drawdown(close))

auto_adjust=Trueを付けているのがポイントです。未調整の価格でMDDを出すと、株式分割の日に「-50%の暴落」が現れます。実際には何も起きていないのに、過去最大の下落として記録されてしまいます。

これを自分の保有銘柄それぞれに回してみると、半導体関連は軒並みMDDが30%を超えていて、正直「よくこれ握ってたな自分」と思いました。

回復までの日数も出す

MDDの数字より、僕にとってきつかったのは戻るまでの時間でした。-30%より、「-30%のまま8か月」の方がメンタルを削ります。

def drawdown_periods(equity: pd.Series, threshold: float = -0.10) -> pd.DataFrame:
    """threshold より深いドローダウン局面を一覧にする。"""
    running_max = equity.cummax()
    dd = (equity - running_max) / running_max

    in_dd = dd < 0
    rows = []
    start = None
    for date, flag in in_dd.items():
        if flag and start is None:
            start = date
        elif not flag and start is not None:
            seg = dd.loc[start:date]
            if seg.min() <= threshold:
                rows.append({
                    "start": start.date(),
                    "trough": seg.idxmin().date(),
                    "end": date.date(),
                    "depth": round(float(seg.min()), 4),
                    "days_total": int((date - start).days),
                    "days_to_recover": int((date - seg.idxmin()).days),
                })
            start = None
    if start is not None:   # まだ回復していない
        seg = dd.loc[start:]
        if seg.min() <= threshold:
            rows.append({
                "start": start.date(),
                "trough": seg.idxmin().date(),
                "end": None,
                "depth": round(float(seg.min()), 4),
                "days_total": int((equity.index[-1] - start).days),
                "days_to_recover": None,
            })
    return pd.DataFrame(rows).sort_values("depth")

print(drawdown_periods(close).head())

endNoneの行は、いま現在まだ高値を回復していない局面です。ここに自分の保有銘柄が並んでいるとき、「もう少し待てば戻る」と考えているのは希望であって根拠ではありません。過去に何日かかったかを見てから判断した方が冷静になれます。

ポートフォリオ単位で出す

個別銘柄のMDDを並べても、自分の資産がどれだけ沈んだかは分かりません。保有比率で合成した資産曲線を作ります。

HOLDINGS = {
    "6857.T": 0.30,   # アドバンテスト
    "8035.T": 0.25,   # 東京エレクトロン
    "7203.T": 0.25,   # トヨタ
    "8306.T": 0.20,   # 三菱UFJ
}

def portfolio_equity(holdings: dict[str, float], period: str = "3y") -> pd.Series:
    closes = {}
    for code in holdings:
        closes[code] = load_close(code, period)
    px = pd.DataFrame(closes).dropna(how="any")
    rets = px.pct_change().dropna()
    w = pd.Series(holdings)
    w = w / w.sum()
    # 毎日リバランスする前提の簡易版
    port_ret = rets[w.index].dot(w)
    return (1 + port_ret).cumprod()

equity = portfolio_equity(HOLDINGS)
print("portfolio", max_drawdown(equity))

for code in HOLDINGS:
    print(code, max_drawdown(load_close(code))["mdd"])

やってみると、ポートフォリオのMDDは個別銘柄のMDDの加重平均より小さくなります。これが分散の効果です。ただし半導体2銘柄のように同じ材料で動くものを並べていると、思ったほど小さくなりません。僕の場合、6857と8035が同じ方向に動きすぎていて、分散になっていませんでした。

相関がどれくらい効いているかは複数銘柄の相関分析で測れます。下落局面だけの相関を見ると、分散できているつもりが崩れているのが分かります。

比率を変えて試す

def compare_weights(variants: dict[str, dict]) -> pd.DataFrame:
    rows = []
    for name, holdings in variants.items():
        eq = portfolio_equity(holdings)
        m = max_drawdown(eq)
        rows.append({
            "pattern": name,
            "mdd": m["mdd"],
            "total_return": round(float(eq.iloc[-1] - 1), 4),
            "trough": m["trough_date"],
        })
    return pd.DataFrame(rows).set_index("pattern")

variants = {
    "現状(半導体55%)": HOLDINGS,
    "半導体30%に減": {"6857.T": 0.15, "8035.T": 0.15, "7203.T": 0.35, "8306.T": 0.35},
    "均等4分割":       {"6857.T": 0.25, "8035.T": 0.25, "7203.T": 0.25, "8306.T": 0.25},
}
print(compare_weights(variants))

リターンとMDDはたいていトレードオフになります。「MDDを5ポイント下げるために、リターンを何ポイント諦めるか」を数字で見てから比率を決める。これができるようになったのが、今回いちばんの収穫でした。

MDDからポジションサイズを逆算する

最大ドローダウンを知ると、次に活きてくるのがポジションサイズの判断です。考え方は単純で、自分が耐えられる下落幅を先に決め、そこから投入額の上限を出すだけです。

def max_position_ratio(strategy_mdd: float, tolerable_dd: float) -> float:
    """
    strategy_mdd : その銘柄・戦略の過去MDD(例 -0.35)
    tolerable_dd : 資産全体で許容する下落(例 -0.10)
    """
    if strategy_mdd >= 0:
        raise ValueError("mdd は負の値")
    return min(1.0, abs(tolerable_dd) / abs(strategy_mdd))

for mdd in [-0.20, -0.35, -0.50]:
    r = max_position_ratio(mdd, -0.10)
    print(f"MDD {mdd:.0%} → 投入上限 {r:.0%}")

過去MDDが-35%の銘柄に資産の30%を入れると、その銘柄だけで全体を約-10%押し下げます。それを許容できるかどうか。答えがノーなら、比率を下げるしかありません。

ただし注意があります。過去のMDDは将来の上限ではありません。「過去最大が-35%だったから-35%までしか下がらない」という保証はどこにもない。僕は安全率として1.5倍を掛け、-35%の銘柄は-50%を想定してサイズを決めるようにしました。

SAFETY = 1.5

def sized_position(capital: float, price: float, mdd: float, tolerable: float = -0.10) -> dict:
    assumed = mdd * SAFETY
    ratio = max_position_ratio(assumed, tolerable)
    budget = capital * ratio
    shares = int(budget // (price * 100)) * 100   # 単元株
    return {
        "assumed_mdd": round(assumed, 3),
        "ratio": round(ratio, 3),
        "budget": round(budget),
        "shares": shares,
    }

ケリー基準など、ボラティリティから直接サイズを出す方法もあります。MDDベースは荒いですが、「いくらまで含み損に耐えるか」という自分の感覚に直結しているぶん、守りやすいと感じています。

quantstatsを使う場合

毎回自分で書くのが面倒な場合は、quantstatsを使うとドローダウン期間の一覧や回復日数まで一発で出ます。

pip install quantstats

import quantstats as qs

returns = close.pct_change().dropna()
qs.reports.metrics(returns, mode="full")
print(qs.stats.max_drawdown(returns))
print(qs.stats.drawdown_details(qs.stats.to_drawdown_series(returns)).head())

環境によっては--break-system-packagesを求められますが、それを付けるより仮想環境を作った方が安全です。python -m venv .venvで1つ作っておけば、システム側のパッケージを壊しません。環境の作り方はPython環境構築ガイドにまとめています。

ライブラリに任せる場合も、最初の1回は自分でcummaxから書いた方がいいと思います。中で何を計算しているか分からないまま数字だけ見ても、判断には使えません。

まとめ

最大ドローダウンは「そのポジション、握り続けられますか?」を数字で突きつけてくる、地味だけど一番大事な指標かもしれません。今回計算してみて、自分がいかに感覚頼みでリスクを取っていたかを思い知らされました。

やったことは4つです。cummaxで個別MDDを出す。回復日数まで一覧化する。保有比率で合成してポートフォリオMDDを出す。そこから投入上限を逆算する。特に最後は、半導体の比率を55%から30%に落とす判断につながりました。

次は、この計算を毎月自動で回して、比率がズレてきたら通知するようにしたいと思っています。数字を出すこと自体より、出し続けることの方がむずかしいので。

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