ケリー基準とボラティリティターゲティングで
ロットを自動計算するPython実装

「勝率の高いロジック」より「壊れないサイジング」が生存を決めます。同じシグナルでもロットの決め方だけで最大ドローダウンは数倍変わり、破産確率はゼロにも1にもなります。この記事では固定リスク・ATRベース・ボラティリティターゲティング・ケリー基準とfractional Kelly・リスクパリティを実装し、Bybitの制約でクリップして実際に発注できる数量にするところまでをサンプルコード8本で作ります。

確認日
2026-07-31
API バージョン
Bybit V5
Python
3.10 以上
依存
numpy / pandas / scipy

※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。

目次

  1. サイジングが結果を支配する
  2. 固定リスクサイジング
  3. ATRベースサイジング
  4. ボラティリティターゲティング
  5. ケリー基準とfractional Kelly
  6. 複数戦略のリスクパリティ配分
  7. 制約を満たすまでクリップする
  8. 方式別のDD比較と記録
  9. よくある疑問Q&A

サイジングが結果を支配する

同じシグナル・同じエントリー・同じ損切りでも、ロットの決め方だけで結果は別物になります。期待値がプラスの戦略でも、サイズが大きすぎれば破産します。

方式必要な入力長所短所
固定枚数 なし 最も単純 資産が増減してもリスクが変わらない。銘柄間で比較不能
固定リスク 損切り距離 1トレードの損失額が一定。直感的 損切りを置かない戦略には使えない
ATRベース ATR 銘柄のボラに自動適応 ATRの係数が恣意的
ボラターゲット 予測ボラ ポートフォリオのリスクを一定化。理論的裏付けあり 売買回数が増えコスト負けしうる
ケリー基準 勝率・損益比 長期成長率を最大化(理論上) 推定誤差に極端に脆弱。実用は 1/4 以下に縮める
リスクパリティ 共分散行列 複数戦略のリスク寄与を均等化 共分散の推定誤差。戦略数が少ないと意味が薄い
サイジングは「期待値」ではなく「生存確率」を最適化する

期待値を最大化するなら全資産を賭けるのが正解ですが、それでは1回の損失で退場します。実際に最大化すべきは対数資産の期待値(幾何平均成長率)で、これがケリー基準の出発点です。
ただしケリーは勝率と損益比が正確に分かっている前提です。推定値を使うと過大なサイズになるため、実務では 1/4 Kelly 以下に縮めます。この記事の各方式は、最終的にサンプル7の clip_to_constraints() を通して初めて発注可能な数量になります。

固定リスクサイジング

最も実装しやすく、最も広く使われる方式です。「1トレードで資産の r% までしか失わない」という制約から数量を逆算します。

sizing_core.pyサンプル1
"""sizing_core.py — サイジングの共通型と固定リスク方式
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  固定リスクの式:

    許容損失額 = 資産 × risk_per_trade
    損切り距離 = |entry − stop|
    数量       = 許容損失額 / 損切り距離

  例: 資産 10,000 USDT / リスク 1% / entry 120,000 / stop 118,800
      許容損失 = 100 USDT
      距離     = 1,200
      数量     = 100 / 1200 = 0.0833 BTC

  注意: 手数料とスリッページを無視すると、実際の損失は許容額を超える。
        往復手数料 + 想定スリッページを距離に加算しておく。
"""
from __future__ import annotations

from dataclasses import dataclass, field
from decimal import Decimal
from enum import Enum


class SizingMethod(str, Enum):
    FIXED_RISK = "固定リスク"
    ATR = "ATRベース"
    VOL_TARGET = "ボラターゲット"
    KELLY = "ケリー基準"
    RISK_PARITY = "リスクパリティ"
    MANUAL = "手動指定"


@dataclass(frozen=True)
class SizingContext:
    """サイジングに必要な入力をまとめる。"""
    equity: Decimal                  # 口座資産(USD換算)
    entry_price: Decimal
    stop_price: Decimal = Decimal(0)  # 損切り価格(0なら未設定)
    side: str = "Buy"
    symbol: str = ""
    taker_fee_rate: Decimal = Decimal("0.00055")   # 往復の片道
    slippage_bps: Decimal = Decimal("3")

    @property
    def stop_distance(self) -> Decimal:
        """損切りまでの価格距離。手数料・スリッページ込み。"""
        if self.stop_price <= 0:
            return Decimal(0)
        raw = abs(self.entry_price - self.stop_price)
        # 往復手数料(2回分)+ 片道スリッページ
        cost = (self.entry_price * self.taker_fee_rate * 2
                + self.entry_price * self.slippage_bps / Decimal("10000"))
        return raw + cost

    @property
    def stop_distance_pct(self) -> Decimal:
        if self.entry_price <= 0:
            return Decimal(0)
        return self.stop_distance / self.entry_price


@dataclass
class SizingResult:
    """サイジングの結果と、その根拠。"""
    method: SizingMethod
    raw_qty: Decimal                  # 制約適用前の理論値
    final_qty: Decimal = Decimal(0)   # クリップ後(サンプル7で埋める)
    risk_amount: Decimal = Decimal(0)  # 想定される損失額
    notional: Decimal = Decimal(0)
    leverage_used: Decimal = Decimal(0)
    reasons: list[str] = field(default_factory=list)
    inputs: dict = field(default_factory=dict)

    def add_reason(self, text: str) -> None:
        self.reasons.append(text)

    def report(self) -> str:
        lines = [
            f"[{self.method.value}]",
            f"  理論数量  : {self.raw_qty}",
            f"  最終数量  : {self.final_qty}",
            f"  建玉額    : {self.notional:.2f} USD",
            f"  想定損失  : {self.risk_amount:.2f} USD",
            f"  実効レバ  : {self.leverage_used:.2f}x",
        ]
        if self.inputs:
            lines.append("  --- 入力 ---")
            lines.extend(f"    {k}: {v}" for k, v in self.inputs.items())
        if self.reasons:
            lines.append("  --- 調整理由 ---")
            lines.extend(f"    - {r}" for r in self.reasons)
        return "\n".join(lines)


def size_by_fixed_risk(ctx: SizingContext,
                       risk_per_trade: Decimal = Decimal("0.01")
                       ) -> SizingResult:
    """固定リスクサイジング。

    risk_per_trade: 1トレードで許容する資産の割合(0.01 = 1%)
    """
    result = SizingResult(method=SizingMethod.FIXED_RISK, raw_qty=Decimal(0))
    result.inputs = {
        "資産": f"{ctx.equity:.2f}",
        "リスク率": f"{risk_per_trade:.2%}",
        "entry": str(ctx.entry_price),
        "stop": str(ctx.stop_price),
    }

    if ctx.stop_price <= 0:
        result.add_reason("損切り価格が未設定のため計算できません")
        return result

    distance = ctx.stop_distance
    if distance <= 0:
        result.add_reason("損切り距離がゼロ以下です")
        return result

    allowed_loss = ctx.equity * risk_per_trade
    result.raw_qty = allowed_loss / distance
    result.risk_amount = allowed_loss
    result.notional = result.raw_qty * ctx.entry_price
    result.leverage_used = (result.notional / ctx.equity
                            if ctx.equity > 0 else Decimal(0))

    result.inputs["損切り距離"] = f"{distance:.2f} ({ctx.stop_distance_pct:.2%})"
    result.inputs["許容損失額"] = f"{allowed_loss:.2f}"

    if result.leverage_used > 10:
        result.add_reason(
            f"実効レバレッジが {result.leverage_used:.1f}倍。"
            "損切りが近すぎる可能性があります")

    return result


def size_by_notional(ctx: SizingContext, notional: Decimal) -> SizingResult:
    """建玉額を直接指定する(比較のベースライン用)。"""
    result = SizingResult(method=SizingMethod.MANUAL,
                          raw_qty=notional / ctx.entry_price)
    result.notional = notional
    result.leverage_used = (notional / ctx.equity
                            if ctx.equity > 0 else Decimal(0))
    if ctx.stop_price > 0:
        result.risk_amount = result.raw_qty * ctx.stop_distance
    return result


if __name__ == "__main__":
    ctx = SizingContext(
        equity=Decimal("10000"),
        entry_price=Decimal("120000"),
        stop_price=Decimal("118800"),
        symbol="BTCUSDT")

    print("=== 損切り距離とリスク率による数量の変化 ===\n")
    print(f"資産: {ctx.equity} USDT / entry: {ctx.entry_price}\n")
    print(f"{'損切り':>10}{'距離':>9}{'0.5%':>11}{'1%':>11}"
          f"{'2%':>11}{'実効レバ(1%)':>14}")
    print("-" * 68)

    for stop_pct in (Decimal("0.005"), Decimal("0.01"), Decimal("0.02"),
                     Decimal("0.03"), Decimal("0.05")):
        stop = ctx.entry_price * (Decimal(1) - stop_pct)
        c = SizingContext(equity=ctx.equity, entry_price=ctx.entry_price,
                          stop_price=stop)
        row = [f"{stop_pct:>9.1%}", f"{c.stop_distance_pct:>8.2%}"]
        for risk in (Decimal("0.005"), Decimal("0.01"), Decimal("0.02")):
            r = size_by_fixed_risk(c, risk)
            row.append(f"{r.raw_qty:>10.4f}")
        r1 = size_by_fixed_risk(c, Decimal("0.01"))
        row.append(f"{r1.leverage_used:>13.2f}x")
        print("".join(row))

    print("\n=== 詳細(リスク1%・損切り1%) ===")
    print(size_by_fixed_risk(ctx, Decimal("0.01")).report())

    print("\n※ 損切りが近いほど数量は増え、実効レバレッジが上がる。")
    print("  『損切りが近いから安全』ではない。")
    print("  近い損切りはノイズで刈られやすく、")
    print("  結果として小さい損失を何度も繰り返すことになる。")

ATRベースサイジング

sizing_atr.pyサンプル2
"""sizing_atr.py — ATRを損切り距離としたサイジング
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  固定リスクの「損切り距離」をATRから決める。
  銘柄ごとのボラティリティに自動で適応するため、
  BTCとアルトで同じ設定が使えるのが利点。

    損切り距離 = ATR × atr_multiple
    数量       = (資産 × risk) / 損切り距離

  atr_multiple の目安:
    1.5〜2.0 : 短期・ノイズを許容しない
    2.5〜3.0 : 標準
    4.0〜    : 長期トレンドフォロー(記事33)
"""
from __future__ import annotations

from decimal import Decimal

import pandas as pd

from sizing_core import SizingContext, SizingMethod, SizingResult


def size_by_atr(ctx: SizingContext, atr_value: Decimal,
                risk_per_trade: Decimal = Decimal("0.01"),
                atr_multiple: Decimal = Decimal("2.5")) -> SizingResult:
    """ATR × 倍数 を損切り距離としてサイジングする。"""
    result = SizingResult(method=SizingMethod.ATR, raw_qty=Decimal(0))
    result.inputs = {
        "資産": f"{ctx.equity:.2f}",
        "リスク率": f"{risk_per_trade:.2%}",
        "ATR": str(atr_value),
        "倍数": str(atr_multiple),
    }

    if atr_value <= 0:
        result.add_reason("ATRがゼロ以下です")
        return result

    distance = atr_value * atr_multiple
    # 手数料とスリッページを距離に加算
    cost = (ctx.entry_price * ctx.taker_fee_rate * 2
            + ctx.entry_price * ctx.slippage_bps / Decimal("10000"))
    distance += cost

    allowed_loss = ctx.equity * risk_per_trade
    result.raw_qty = allowed_loss / distance
    result.risk_amount = allowed_loss
    result.notional = result.raw_qty * ctx.entry_price
    result.leverage_used = (result.notional / ctx.equity
                            if ctx.equity > 0 else Decimal(0))

    result.inputs["損切り距離"] = (
        f"{distance:.2f} ({distance / ctx.entry_price:.2%})")
    return result


def implied_stop_price(ctx: SizingContext, atr_value: Decimal,
                       atr_multiple: Decimal = Decimal("2.5")) -> Decimal:
    """ATRサイジングに対応する損切り価格。

    サイジングと損切り注文で同じ距離を使わないと、
    想定リスクと実際のリスクがずれる。
    """
    distance = atr_value * atr_multiple
    if ctx.side == "Buy":
        return ctx.entry_price - distance
    return ctx.entry_price + distance


def size_multi_symbol(http, symbols: list[str], equity: Decimal,
                      risk_per_trade: Decimal = Decimal("0.01"),
                      atr_multiple: Decimal = Decimal("2.5"),
                      interval: str = "60") -> pd.DataFrame:
    """複数銘柄それぞれのATRサイジングを一覧にする。"""
    from atr_tools import atr           # 記事12のサンプル6
    from kline_fetch import fetch_klines

    rows = []
    for symbol in symbols:
        try:
            bars = fetch_klines(http, symbol, interval=interval,
                                limit_bars=200, verbose=False)
            atr_val = Decimal(str(float(atr(bars, 14).iloc[-1])))
            price = Decimal(str(float(bars["close"].iloc[-1])))

            ctx = SizingContext(equity=equity, entry_price=price,
                                symbol=symbol)
            r = size_by_atr(ctx, atr_val, risk_per_trade, atr_multiple)

            rows.append({
                "symbol": symbol,
                "price": float(price),
                "atr": float(atr_val),
                "atr_pct": float(atr_val / price * 100),
                "qty": float(r.raw_qty),
                "notional": float(r.notional),
                "leverage": float(r.leverage_used),
                "stop": float(implied_stop_price(ctx, atr_val, atr_multiple)),
            })
        except Exception as e:
            print(f"  [warn] {symbol}: {e}")

    return pd.DataFrame(rows)


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    equity = Decimal("10000")

    df = size_multi_symbol(
        c.http, ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "DOGEUSDT"],
        equity=equity, risk_per_trade=Decimal("0.01"),
        atr_multiple=Decimal("2.5"))

    if df.empty:
        print("データを取得できませんでした。")
    else:
        print(f"=== ATRサイジング(資産 {equity} USDT / リスク1% "
              f"/ ATR×2.5) ===\n")
        print(f"{'symbol':<11}{'価格':>12}{'ATR%':>8}"
              f"{'数量':>14}{'建玉額':>12}{'実効レバ':>10}{'損切り':>12}")
        print("-" * 80)
        for _, r in df.iterrows():
            print(f"{r['symbol']:<11}{r['price']:>12.4f}"
                  f"{r['atr_pct']:>7.2f}%{r['qty']:>14.4f}"
                  f"{r['notional']:>12.2f}{r['leverage']:>9.2f}x"
                  f"{r['stop']:>12.4f}")

        print(f"\n  建玉額の合計: {df['notional'].sum():.2f} USD")
        print(f"  想定損失合計: {float(equity) * 0.01 * len(df):.2f} USD")
        print("\n  ※ 全銘柄に同時エントリーすると、")
        print("    1%×銘柄数 のリスクを取ることになる。")
        print("    相関が高い銘柄同士では実質的に同じ賭けを重ねている。")
        print("    → サンプル6のリスクパリティで調整すること。")

    print("\n【ATRサイジングの本質】")
    print("  ボラの高い銘柄は自動的に数量が小さくなる。")
    print("  『どの銘柄でも同じ金額を賭ける』より合理的だが、")
    print("  atr_multiple の値そのものには理論的根拠が無い。")
    print("  記事31のバックテストで自分の戦略に合う値を探すこと。")

ボラティリティターゲティング

目標とする年率ボラティリティを決め、予測ボラでスケールします。損切りを置かない戦略でも使えるのが利点です。

sizing_voltarget.pyサンプル3
"""sizing_voltarget.py — ボラティリティターゲティング
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

参考: Moskowitz, Ooi & Pedersen (2012) "Time Series Momentum"
      Moreira, A. & Muir, T. (2017) "Volatility-Managed Portfolios",
        Journal of Finance 72(4)
      Harvey et al. (2018) "The Impact of Volatility Targeting"

  基本式:
    エクスポージャー = 資産 × (目標ボラ / 予測ボラ)
    数量           = エクスポージャー / 価格

  「ボラが高いときは小さく、低いときは大きく」持つだけの規則が、
  多くの戦略でリスク調整後リターンを改善する。

  ただし:
    - ボラが低い局面で過大なレバレッジになる → 上限クリップが必須
    - 売買回数が増える → 手数料負けの検証が必要(記事31)
"""
from __future__ import annotations

from decimal import Decimal

import numpy as np
import pandas as pd

from sizing_core import SizingContext, SizingMethod, SizingResult


def size_by_vol_target(ctx: SizingContext, forecast_vol: Decimal,
                       target_vol: Decimal = Decimal("0.30"),
                       max_leverage: Decimal = Decimal("3"),
                       min_leverage: Decimal = Decimal("0")) -> SizingResult:
    """予測ボラから目標ボラになるようスケールする。

    forecast_vol: 年率ボラの予測値(記事12のYang-ZhangやHAR-RV)
    target_vol  : 目標年率ボラ(0.30 = 30%)
    """
    result = SizingResult(method=SizingMethod.VOL_TARGET, raw_qty=Decimal(0))
    result.inputs = {
        "資産": f"{ctx.equity:.2f}",
        "目標ボラ": f"{target_vol:.1%}",
        "予測ボラ": f"{forecast_vol:.1%}",
    }

    if forecast_vol <= 0:
        result.add_reason("予測ボラがゼロ以下です")
        return result

    scale = target_vol / forecast_vol
    clipped = max(min_leverage, min(scale, max_leverage))

    if clipped != scale:
        result.add_reason(
            f"倍率 {scale:.2f} を {clipped:.2f} にクリップ "
            f"(上限 {max_leverage})")

    exposure = ctx.equity * clipped
    result.raw_qty = exposure / ctx.entry_price
    result.notional = exposure
    result.leverage_used = clipped
    result.inputs["倍率"] = f"{clipped:.3f}"

    if ctx.stop_price > 0:
        result.risk_amount = result.raw_qty * ctx.stop_distance

    return result


def forecast_volatility(bars: pd.DataFrame, window: int = 24 * 7,
                        bars_per_year: int = 8_760,
                        method: str = "yang_zhang",
                        blend_ewma: bool = True) -> Decimal:
    """次期のボラを予測する。

    実務的には「直近の実績値をそのまま使う」で十分なことが多い(記事12)。
    ここでは推定量と短期EWMAをブレンドして、
    急変への追随と安定性のバランスを取る。
    """
    from vol_estimators import ESTIMATORS      # 記事12のサンプル1

    estimator = ESTIMATORS.get(method)
    if estimator is None:
        raise ValueError(f"未知の推定量: {method}")

    series = estimator(bars, window, bars_per_year).dropna()
    if series.empty:
        raise ValueError("ボラを推定できませんでした")

    slow = float(series.iloc[-1])

    if not blend_ewma:
        return Decimal(str(slow))

    # 短い窓の推定値(急変に速く反応する)
    fast_series = estimator(bars, max(4, window // 6), bars_per_year).dropna()
    fast = float(fast_series.iloc[-1]) if not fast_series.empty else slow

    # 上振れ側を重視する(リスクを過小評価しないため)
    blended = max(slow, 0.5 * slow + 0.5 * fast)
    return Decimal(str(blended))


def vol_target_series(bars: pd.DataFrame, target_vol: float = 0.30,
                      window: int = 24 * 7, bars_per_year: int = 8_760,
                      max_leverage: float = 3.0) -> pd.Series:
    """全期間の倍率系列を作る(バックテスト用)。

    重要: shift(1) して、そのバーの終値時点で知り得た情報だけを使う。
    """
    from vol_estimators import yang_zhang

    vol = yang_zhang(bars, window, bars_per_year).shift(1)
    scale = (target_vol / vol).clip(0.0, max_leverage)
    return scale.fillna(0.0)


def leverage_distribution(scale: pd.Series) -> None:
    """倍率の分布を確認する。上限に張り付いていないか。"""
    s = scale.dropna()
    if s.empty:
        print("  データがありません。")
        return

    print(f"  平均 {s.mean():.2f}倍 / 中央値 {s.median():.2f}倍 "
          f"/ 最大 {s.max():.2f}倍")
    print(f"  分位点: "
          f"10%={s.quantile(0.1):.2f} "
          f"25%={s.quantile(0.25):.2f} "
          f"75%={s.quantile(0.75):.2f} "
          f"90%={s.quantile(0.9):.2f}")

    at_cap = (s >= s.max() * 0.999).mean()
    print(f"  上限張り付き率: {at_cap:.1%}")
    if at_cap > 0.3:
        print("  → 3割以上が上限。目標ボラが高すぎるか上限が低すぎます")


if __name__ == "__main__":
    from client import make_client
    from kline_fetch import fetch_klines

    c = make_client()
    symbol = "BTCUSDT"

    bars = fetch_klines(c.http, symbol, interval="60",
                        start="2025-01-01", verbose=False)
    price = Decimal(str(float(bars["close"].iloc[-1])))
    equity = Decimal("10000")

    forecast = forecast_volatility(bars)
    print(f"=== ボラティリティターゲティング ({symbol}) ===\n")
    print(f"  現在価格  : {price}")
    print(f"  予測年率ボラ: {forecast:.1%}\n")

    ctx = SizingContext(equity=equity, entry_price=price, symbol=symbol)

    print(f"{'目標ボラ':>10}{'倍率':>9}{'数量':>13}"
          f"{'建玉額':>13}{'備考':>10}")
    print("-" * 58)
    for target in (Decimal("0.15"), Decimal("0.30"), Decimal("0.50"),
                   Decimal("0.80"), Decimal("1.20")):
        r = size_by_vol_target(ctx, forecast, target,
                               max_leverage=Decimal("3"))
        note = "上限" if r.reasons else ""
        print(f"{target:>9.0%}{r.leverage_used:>8.2f}x"
              f"{r.raw_qty:>13.4f}{r.notional:>13.2f}{note:>10}")

    print("\n=== 倍率の分布(過去1年) ===")
    scale = vol_target_series(bars, target_vol=0.30, max_leverage=3.0)
    leverage_distribution(scale)

    print("\n【運用上の注意】")
    print("  - 目標ボラは『取りたいリスク』であって『得られるリターン』ではない。")
    print("  - 高く設定すればDDも比例して大きくなる。")
    print("  - 実務では 20〜40% あたりから始め、")
    print("    記事31のバックテストでDDを確認して調整する。")
    print("  - max_leverage を外すと、静穏期に危険なサイズになる。")

ケリー基準とfractional Kelly

ケリー基準は対数資産の期待値(幾何平均成長率)を最大化する賭け率を与えます。理論的には最適ですが、推定誤差に極端に脆弱なので、実務ではそのまま使いません。

sizing_kelly.pyサンプル4
"""sizing_kelly.py — ケリー基準・fractional Kelly・シュリンク推定
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

参考: Kelly, J.L. (1956) "A New Interpretation of Information Rate"
      Thorp, E. (2006) "The Kelly Criterion in Blackjack, Sports Betting,
        and the Stock Market"
      MacLean, Thorp & Ziemba (2011) "The Kelly Capital Growth
        Investment Criterion"

  離散版(勝ち負けが二値):
    f* = p − q/b       p=勝率, q=1−p, b=損益比(平均利益/平均損失)

  連続版(リターンが連続分布):
    f* ≈ μ / σ²        μ=期待リターン, σ²=分散
       = シャープレシオ / ボラティリティ

  重大な注意:
    - f* は p と b が「正確に分かっている」前提の値
    - 推定値を使うと、誤差により f* は過大になる
    - 実務では 1/4 Kelly 以下(f*/4)が定石
    - フルケリーは「資産が1/2になる確率が50%」という激しさ
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

import numpy as np
import pandas as pd
from scipy import stats

from sizing_core import SizingContext, SizingMethod, SizingResult


@dataclass
class KellyEstimate:
    win_rate: float
    payoff_ratio: float
    full_kelly: float
    shrunk_kelly: float          # 推定誤差を織り込んだ値
    fractional: float            # 実際に使う値
    fraction: float
    n_trades: int
    se_win_rate: float           # 勝率の標準誤差
    confidence_note: str = ""

    def report(self) -> str:
        return "\n".join([
            f"  取引数      : {self.n_trades}",
            f"  勝率        : {self.win_rate:.2%} "
            f"(±{self.se_win_rate:.2%})",
            f"  損益比      : {self.payoff_ratio:.3f}",
            f"  フルケリー  : {self.full_kelly:>7.2%}",
            f"  シュリンク後: {self.shrunk_kelly:>7.2%}",
            f"  実用値 (1/{int(1 / self.fraction)}): "
            f"{self.fractional:>7.2%}",
            f"  {self.confidence_note}",
        ])


def kelly_discrete(win_rate: float, payoff_ratio: float) -> float:
    """離散版ケリー。f* = p − q/b"""
    if payoff_ratio <= 0:
        return 0.0
    return win_rate - (1 - win_rate) / payoff_ratio


def kelly_continuous(mean_return: float, variance: float) -> float:
    """連続版ケリー。f* = μ / σ²"""
    if variance <= 0:
        return 0.0
    return mean_return / variance


def estimate_kelly(trade_returns: pd.Series,
                   fraction: float = 0.25,
                   shrink: bool = True) -> KellyEstimate:
    """過去のトレード損益からケリー比率を推定する。

    trade_returns: 1トレードあたりのリターン(資産比)。
                   例: +0.02 は資産の2%の利益。
    """
    r = trade_returns.dropna()
    n = len(r)
    if n < 10:
        return KellyEstimate(0, 0, 0, 0, 0, fraction, n, 0,
                             "取引数が少なすぎます(10件未満)")

    wins = r[r > 0]
    losses = r[r < 0]

    win_rate = len(wins) / n
    avg_win = float(wins.mean()) if len(wins) else 0.0
    avg_loss = float(-losses.mean()) if len(losses) else 0.0
    payoff = avg_win / avg_loss if avg_loss > 0 else 0.0

    full = kelly_discrete(win_rate, payoff)

    # 勝率の標準誤差(二項分布)
    se = float(np.sqrt(win_rate * (1 - win_rate) / n))

    shrunk = full
    note = ""
    if shrink:
        # 保守側の勝率(1標準誤差下)で再計算する
        conservative_p = max(0.0, win_rate - se)
        shrunk = kelly_discrete(conservative_p, payoff)
        shrunk = max(0.0, shrunk)

        if n < 100:
            note = (f"取引数 {n} 件では推定が不安定です。"
                    "さらに小さく使ってください")
        elif full > 0 and shrunk / full < 0.5:
            note = ("推定誤差の影響が大きい"
                    f"({full:.1%} → {shrunk:.1%})")
        else:
            note = "推定は比較的安定しています"

    return KellyEstimate(
        win_rate=win_rate, payoff_ratio=payoff,
        full_kelly=full, shrunk_kelly=shrunk,
        fractional=max(0.0, shrunk * fraction),
        fraction=fraction, n_trades=n, se_win_rate=se,
        confidence_note=note)


def size_by_kelly(ctx: SizingContext, estimate: KellyEstimate,
                  max_leverage: Decimal = Decimal("3")) -> SizingResult:
    """ケリー比率を建玉額の割合として適用する。"""
    result = SizingResult(method=SizingMethod.KELLY, raw_qty=Decimal(0))
    result.inputs = {
        "勝率": f"{estimate.win_rate:.2%}",
        "損益比": f"{estimate.payoff_ratio:.3f}",
        "フルケリー": f"{estimate.full_kelly:.2%}",
        "適用値": f"{estimate.fractional:.2%}",
    }

    if estimate.fractional <= 0:
        result.add_reason(
            "ケリー比率が非正です。この戦略には資金を投じるべきではありません")
        return result

    f = Decimal(str(estimate.fractional))
    if f > max_leverage:
        result.add_reason(f"倍率 {f:.2f} を上限 {max_leverage} にクリップ")
        f = max_leverage

    exposure = ctx.equity * f
    result.raw_qty = exposure / ctx.entry_price
    result.notional = exposure
    result.leverage_used = f

    if ctx.stop_price > 0:
        result.risk_amount = result.raw_qty * ctx.stop_distance

    if estimate.n_trades < 100:
        result.add_reason(
            f"取引サンプルが {estimate.n_trades} 件と少なく、推定が不安定です")

    return result


def simulate_kelly_fractions(trade_returns: pd.Series,
                             fractions: tuple[float, ...] = (
                                 0.1, 0.25, 0.5, 1.0, 1.5),
                             n_paths: int = 500,
                             n_trades: int = 500,
                             seed: int = 42) -> pd.DataFrame:
    """ケリー比率の倍率別に、資産推移をブートストラップで比較する。

    「フルケリーがなぜ危険か」を数字で見る。
    """
    rng = np.random.default_rng(seed)
    r = trade_returns.dropna().to_numpy()
    if len(r) < 10:
        return pd.DataFrame()

    est = estimate_kelly(trade_returns, fraction=1.0, shrink=False)
    base_f = est.full_kelly
    if base_f <= 0:
        return pd.DataFrame()

    # 1トレードの損益を「平均損失1単位」で正規化する。
    # これにより f は「平均的な負けトレードで失う資産の割合」になる。
    avg_loss = float(-r[r < 0].mean()) if (r < 0).any() else float(abs(r).mean())
    unit = r / max(avg_loss, 1e-12)

    rows = []
    for frac in fractions:
        f = base_f * frac
        finals, max_dds, ruins = [], [], 0

        for _ in range(n_paths):
            sample = rng.choice(unit, size=n_trades, replace=True)
            # 資産比 f を賭けたときの資産成長
            growth = np.clip(1 + f * sample, 1e-9, None)
            equity = np.cumprod(growth)

            finals.append(equity[-1])
            peak = np.maximum.accumulate(equity)
            max_dds.append(float((equity / peak - 1).min()))
            if equity.min() < 0.1:      # 資産が1/10になったら破産扱い
                ruins += 1

        rows.append({
            "倍率": f"{frac:.2f} Kelly",
            "賭け率": f,
            "資産中央値": float(np.median(finals)),
            "資産5%点": float(np.percentile(finals, 5)),
            "最大DD中央値": float(np.median(max_dds)),
            "最悪DD": float(np.min(max_dds)),
            "破産率": ruins / n_paths,
        })

    return pd.DataFrame(rows).set_index("倍率")


if __name__ == "__main__":
    # 実際のトレード履歴が無い場合の合成データ
    rng = np.random.default_rng(0)
    n = 400
    wins = rng.normal(0.020, 0.010, int(n * 0.45))
    losses = rng.normal(-0.014, 0.006, n - int(n * 0.45))
    returns = pd.Series(np.concatenate([wins, losses]))
    returns = returns.sample(frac=1, random_state=1).reset_index(drop=True)

    print("=== ケリー比率の推定 ===")
    for frac in (1.0, 0.5, 0.25):
        est = estimate_kelly(returns, fraction=frac)
        print(f"\n[1/{int(1 / frac)} Kelly]")
        print(est.report())

    print("\n\n=== 倍率別のシミュレーション(500パス×500トレード) ===")
    sim = simulate_kelly_fractions(returns)
    if not sim.empty:
        print(sim.to_string(float_format=lambda v: f"{v:.4f}"))

    print("\n【読み方】")
    print("  フルケリー(1.00)は資産中央値が最大になるが、")
    print("  最大DDと破産率も跳ね上がる。")
    print("  0.25 Kelly なら成長率の大半を保ちつつ、")
    print("  DDは大幅に小さくなる。")
    print("\n※ ここで使った勝率・損益比は『過去の推定値』。")
    print("  将来も同じ値である保証は無い。")
    print("  推定誤差を考えると、実務では 0.25 でも強気の部類。")
    print("  記事32の Deflated Sharpe Ratio で、")
    print("  その優位性が偶然でないことを先に確認すること。")

複数戦略のリスクパリティ配分

複数の戦略・銘柄を同時に走らせるとき、各戦略に同じ金額を割り当てるとリスク寄与は均等になりません。ボラの高い戦略が全体のリスクを支配します。

sizing_parity.pyサンプル5
"""sizing_parity.py — 逆ボラウェイト・リスクパリティ・HRPによる配分
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

参考: Maillard, Roncalli & Teiletche (2010)
        "The Properties of Equally Weighted Risk Contribution Portfolios"
      López de Prado, M. (2016) "Building Diversified Portfolios
        that Outperform Out-of-Sample" (HRP)

  3段階の複雑さ:
    1. 逆ボラウェイト  : w_i ∝ 1/σ_i        相関を無視。最も単純
    2. リスクパリティ  : 各資産のリスク寄与を均等化。相関を考慮
    3. HRP            : 階層クラスタリングで共分散の推定誤差に頑健

  戦略数が3〜5程度なら逆ボラで十分なことが多い。
  共分散行列の推定誤差は資産数が増えるほど深刻になる。
"""
from __future__ import annotations

import numpy as np
import pandas as pd
from scipy.cluster.hierarchy import linkage, to_tree
from scipy.spatial.distance import squareform


def inverse_vol_weights(returns: pd.DataFrame) -> pd.Series:
    """逆ボラウェイト。w_i ∝ 1/σ_i"""
    vol = returns.std()
    vol = vol.replace(0, np.nan).dropna()
    if vol.empty:
        return pd.Series(dtype=float)
    w = 1 / vol
    return w / w.sum()


def risk_contributions(weights: np.ndarray, cov: np.ndarray) -> np.ndarray:
    """各資産のリスク寄与(合計がポートフォリオ分散になる)。"""
    port_var = float(weights @ cov @ weights)
    if port_var <= 0:
        return np.zeros_like(weights)
    marginal = cov @ weights
    return weights * marginal / np.sqrt(port_var)


def risk_parity_weights(returns: pd.DataFrame,
                        max_iter: int = 2000,
                        tol: float = 1e-10) -> pd.Series:
    """リスク寄与を均等化するウェイトを反復法で求める。

    解析解は無いので、Newton的な逐次更新で収束させる。
    """
    cov = returns.cov().to_numpy()
    n = cov.shape[0]
    if n == 0:
        return pd.Series(dtype=float)

    w = np.ones(n) / n
    target = 1.0 / n

    for _ in range(max_iter):
        port_vol = np.sqrt(max(float(w @ cov @ w), 1e-18))
        rc = risk_contributions(w, cov) / port_vol
        # 目標より寄与が大きい資産のウェイトを下げる
        w_new = w * (target / np.maximum(rc, 1e-12)) ** 0.5
        w_new = np.maximum(w_new, 1e-12)
        w_new /= w_new.sum()

        if np.max(np.abs(w_new - w)) < tol:
            w = w_new
            break
        w = w_new

    return pd.Series(w, index=returns.columns)


def _quasi_diagonal(link: np.ndarray) -> list[int]:
    """クラスタリング結果を、相関の近い順に並べ替える。"""
    tree = to_tree(link, rd=False)
    return list(tree.pre_order())


def _recursive_bisection(cov: pd.DataFrame, order: list[int]) -> pd.Series:
    """再帰的にリスクを二分配分する(HRPの核)。"""
    w = pd.Series(1.0, index=order)
    clusters = [order]

    while clusters:
        next_clusters = []
        for c in clusters:
            if len(c) <= 1:
                continue
            mid = len(c) // 2
            left, right = c[:mid], c[mid:]

            def cluster_var(items: list[int]) -> float:
                sub = cov.iloc[items, items].to_numpy()
                inv_diag = 1 / np.diag(sub)
                iw = inv_diag / inv_diag.sum()
                return float(iw @ sub @ iw)

            var_l, var_r = cluster_var(left), cluster_var(right)
            alpha = 1 - var_l / (var_l + var_r) if (var_l + var_r) > 0 else 0.5

            w[left] *= alpha
            w[right] *= (1 - alpha)
            next_clusters.extend([left, right])
        clusters = next_clusters

    return w


def hrp_weights(returns: pd.DataFrame) -> pd.Series:
    """Hierarchical Risk Parity(López de Prado 2016)。

    共分散行列の逆行列を使わないため、
    推定誤差や特異行列に対して頑健。
    """
    if returns.shape[1] < 2:
        return pd.Series(1.0, index=returns.columns)

    corr = returns.corr()
    cov = returns.cov()

    # 相関を距離に変換: d = sqrt((1−ρ)/2)
    dist = np.sqrt(np.clip((1 - corr) / 2, 0, 1))
    np.fill_diagonal(dist.values, 0.0)

    link = linkage(squareform(dist.to_numpy(), checks=False), method="single")
    order = _quasi_diagonal(link)

    w = _recursive_bisection(cov, order)
    w = w / w.sum()
    return pd.Series(w.to_numpy(), index=corr.columns[w.index])


def compare_allocations(returns: pd.DataFrame) -> pd.DataFrame:
    """4方式のウェイトとリスク寄与を比較する。"""
    cov = returns.cov().to_numpy()
    n = returns.shape[1]

    methods = {
        "均等": pd.Series(1 / n, index=returns.columns),
        "逆ボラ": inverse_vol_weights(returns),
        "リスクパリティ": risk_parity_weights(returns),
        "HRP": hrp_weights(returns),
    }

    rows = []
    for name, w in methods.items():
        w = w.reindex(returns.columns).fillna(0)
        arr = w.to_numpy()
        port_vol = float(np.sqrt(max(arr @ cov @ arr, 0)))
        rc = risk_contributions(arr, cov)
        rc_pct = rc / rc.sum() if rc.sum() != 0 else rc

        row = {"方式": name, "ポートボラ": port_vol}
        for sym, weight in zip(returns.columns, arr):
            row[f"w_{sym}"] = weight
        row["リスク寄与の偏り"] = float(np.std(rc_pct))
        rows.append(row)

    return pd.DataFrame(rows).set_index("方式")


if __name__ == "__main__":
    from client import make_client
    from portfolio_var import fetch_return_matrix    # 記事22のサンプル5

    c = make_client()
    symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "DOGEUSDT"]

    returns = fetch_return_matrix(c.http, symbols, days=120)
    print(f"リターンデータ: {len(returns)}日 × {returns.shape[1]}銘柄\n")

    print("=== 相関行列 ===")
    print(returns.corr().to_string(float_format=lambda v: f"{v:.3f}"))

    print("\n=== 日次ボラティリティ ===")
    for sym, v in returns.std().items():
        print(f"  {sym:<10}{v * 100:>7.3f}%  (年率 {v * np.sqrt(365):.1%})")

    print("\n=== 配分方式の比較 ===")
    comp = compare_allocations(returns)
    print(comp.to_string(float_format=lambda v: f"{v:.4f}"))

    print("\n【読み方】")
    print("  『リスク寄与の偏り』が小さいほど、")
    print("  各資産が均等にリスクを分担している。")
    print("  均等ウェイトは金額は均等でも、")
    print("  ボラの高い資産がリスクを支配していることが分かる。")

    print("\n※ 配分ウェイトが決まったら、")
    print("  各戦略の資金 = 総資産 × ウェイト として")
    print("  サンプル1〜4のサイジングに渡す。")
    print("  戦略数が3以下ならHRPを使う意味は薄い(クラスタが作れない)。")

制約を満たすまでクリップする

理論値をそのまま発注するとほぼ必ず弾かれます。ロットステップ・最小数量・最小建玉額・レバレッジ上限・利用可能証拠金——すべてを通してはじめて発注可能な数量になります。

sizing_clip.pyサンプル6
"""sizing_clip.py — 理論数量を発注可能な数量にクリップする
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  適用する制約(この順序が重要):
    1. 最大レバレッジ         → 建玉額の上限
    2. 利用可能証拠金         → 実際に発注できる上限
    3. リスクリミット階層     → 建玉額が階層を跨がないか(記事21)
    4. 最大注文数量 (maxOrderQty)
    5. 最小注文数量 (minOrderQty) → 満たさなければ発注しない
    6. 最小建玉額 (minNotionalValue)
    7. ロットステップ (qtyStep)   → 必ず切り捨て(切り上げると制約超過)

  最後に切り捨てるのがポイント。
  先に丸めてから制約を確認すると、丸めで上限を超えることがある。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal, ROUND_DOWN

from client import make_client
from sizing_core import SizingContext, SizingResult
from symbol_rules import fetch_rules


@dataclass(frozen=True)
class Constraints:
    max_leverage: Decimal = Decimal("5")
    max_notional_ratio: Decimal = Decimal("0.8")   # 利用可能証拠金の何割まで
    available_margin: Decimal = Decimal(0)          # 0なら資産から推定
    max_position_notional: Decimal = Decimal(0)     # 0なら無制限


def clip_to_constraints(http, ctx: SizingContext, result: SizingResult,
                        constraints: Constraints = Constraints(),
                        category: str = "linear") -> SizingResult:
    """理論数量を発注可能な数量に落とし込む。"""
    if result.raw_qty <= 0:
        result.final_qty = Decimal(0)
        result.add_reason("理論数量がゼロ以下です")
        return result

    rules = fetch_rules(http, ctx.symbol, category)
    qty = result.raw_qty

    # 1) 最大レバレッジ
    max_notional_lev = ctx.equity * constraints.max_leverage
    if qty * ctx.entry_price > max_notional_lev:
        qty = max_notional_lev / ctx.entry_price
        result.add_reason(
            f"最大レバレッジ {constraints.max_leverage}x で制限")

    # 2) 利用可能証拠金
    available = (constraints.available_margin if constraints.available_margin > 0
                 else ctx.equity)
    max_notional_margin = (available * constraints.max_notional_ratio
                           * constraints.max_leverage)
    if qty * ctx.entry_price > max_notional_margin:
        qty = max_notional_margin / ctx.entry_price
        result.add_reason(
            f"利用可能証拠金の{constraints.max_notional_ratio:.0%}で制限")

    # 3) ポジション建玉額の上限(リスクリミット階層など)
    if constraints.max_position_notional > 0:
        if qty * ctx.entry_price > constraints.max_position_notional:
            qty = constraints.max_position_notional / ctx.entry_price
            result.add_reason("ポジション建玉額の上限で制限")

    # 4) 最大注文数量
    if rules.max_qty > 0 and qty > rules.max_qty:
        qty = rules.max_qty
        result.add_reason(
            f"maxOrderQty {rules.max_qty} で制限。"
            "分割発注が必要です(記事16)")

    # 5) ロットステップで切り捨て
    qty = rules.round_qty(qty)

    # 6) 最小注文数量
    if qty < rules.min_qty:
        result.final_qty = Decimal(0)
        result.add_reason(
            f"最小数量 {rules.min_qty} を下回るため発注できません "
            f"(計算値 {qty})")
        return result

    # 7) 最小建玉額
    notional = qty * ctx.entry_price
    min_notional = getattr(rules, "min_notional", Decimal(0))
    if min_notional > 0 and notional < min_notional:
        needed = rules.round_qty_up(min_notional / ctx.entry_price)
        if needed * ctx.entry_price <= max_notional_lev:
            qty = needed
            result.add_reason(
                f"最小建玉額 {min_notional} を満たすため "
                f"{qty} に切り上げ")
        else:
            result.final_qty = Decimal(0)
            result.add_reason(
                f"最小建玉額 {min_notional} を満たすと"
                "レバレッジ上限を超えるため発注できません")
            return result

    result.final_qty = qty
    result.notional = qty * ctx.entry_price
    result.leverage_used = (result.notional / ctx.equity
                            if ctx.equity > 0 else Decimal(0))
    if ctx.stop_price > 0:
        result.risk_amount = qty * ctx.stop_distance

    # 理論値からの乖離を記録する
    if result.raw_qty > 0:
        deviation = abs(qty - result.raw_qty) / result.raw_qty
        if deviation > Decimal("0.20"):
            result.add_reason(
                f"理論値から {deviation:.1%} 乖離しています。"
                "リスク量が想定と異なります")

    return result


def size_and_clip(http, ctx: SizingContext, result: SizingResult,
                  constraints: Constraints = Constraints(),
                  category: str = "linear") -> SizingResult:
    """サイジング結果を制約適用まで通す便利関数。"""
    return clip_to_constraints(http, ctx, result, constraints, category)


if __name__ == "__main__":
    from margin_snapshot import fetch_account_margin     # 記事22のサンプル1
    from sizing_core import size_by_fixed_risk

    c = make_client()

    try:
        margin = fetch_account_margin(c.http)
        equity = margin.total_margin_balance
        available = margin.total_available_balance
    except Exception:
        equity, available = Decimal("10000"), Decimal("10000")

    print(f"=== 制約適用の検証 (資産 {equity:.2f} USD) ===\n")

    constraints = Constraints(
        max_leverage=Decimal("5"),
        available_margin=available,
        max_notional_ratio=Decimal("0.8"))

    for symbol, price in (("BTCUSDT", Decimal("120000")),
                          ("ETHUSDT", Decimal("3900")),
                          ("DOGEUSDT", Decimal("0.18"))):
        for risk_pct in (Decimal("0.0001"), Decimal("0.01"), Decimal("0.20")):
            ctx = SizingContext(
                equity=equity, entry_price=price,
                stop_price=price * Decimal("0.99"), symbol=symbol)

            result = size_by_fixed_risk(ctx, risk_pct)
            result = clip_to_constraints(c.http, ctx, result, constraints)

            status = ("発注不可" if result.final_qty <= 0
                      else f"{result.final_qty}")
            print(f"  {symbol:<10} リスク{risk_pct:>6.2%}: "
                  f"理論 {result.raw_qty:>12.6f} → {status}")
            for reason in result.reasons:
                print(f"      └ {reason}")
        print()

    print("※ 資産が小さいと、リスク0.01%では最小ロットに届かない。")
    print("  逆にリスク20%ではレバレッジ上限で頭打ちになる。")
    print("  『理論値がそのまま通る』ことのほうが稀だと考えること。")

方式別のDD比較と記録

sizing_compare.pyサンプル7
"""sizing_compare.py — 同一シグナルでサイジングだけ変えて比較する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  「サイジングが結果を支配する」ことを数字で確認する。
  シグナル(いつ買っていつ売るか)は完全に同一にし、
  サイズの決め方だけを変えて、エクイティカーブとDDを比較する。

  評価指標:
    総リターン / 年率リターン / 年率ボラ
    最大DD / Calmar (年率リターン ÷ 最大DD)
    シャープ / ソルティノ
"""
from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd

BARS_PER_YEAR = 8_760      # 1時間足


@dataclass
class PerformanceStats:
    name: str
    total_return: float
    annual_return: float
    annual_vol: float
    sharpe: float
    sortino: float
    max_drawdown: float
    calmar: float
    avg_leverage: float
    max_leverage: float
    turnover: float

    def line(self) -> str:
        return (f"{self.name:<18}"
                f"{self.total_return:>+10.1%}"
                f"{self.annual_return:>+10.1%}"
                f"{self.annual_vol:>9.1%}"
                f"{self.sharpe:>8.2f}"
                f"{self.max_drawdown:>9.1%}"
                f"{self.calmar:>8.2f}"
                f"{self.avg_leverage:>8.2f}x")


def evaluate(equity: pd.Series, leverage: pd.Series,
             name: str) -> PerformanceStats:
    """エクイティカーブから統計を計算する。"""
    ret = equity.pct_change().dropna()
    if ret.empty or equity.iloc[0] <= 0:
        return PerformanceStats(name, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)

    total = float(equity.iloc[-1] / equity.iloc[0] - 1)
    years = len(ret) / BARS_PER_YEAR
    annual = float((equity.iloc[-1] / equity.iloc[0]) ** (1 / years) - 1) \
        if years > 0 and equity.iloc[-1] > 0 else 0.0

    vol = float(ret.std() * np.sqrt(BARS_PER_YEAR))
    sharpe = annual / vol if vol > 0 else 0.0

    downside = ret[ret < 0]
    dvol = float(downside.std() * np.sqrt(BARS_PER_YEAR)) if len(downside) else 0.0
    sortino = annual / dvol if dvol > 0 else 0.0

    peak = equity.cummax()
    dd = float((equity / peak - 1).min())
    calmar = annual / abs(dd) if dd < 0 else 0.0

    turnover = float(leverage.diff().abs().sum())

    return PerformanceStats(
        name=name, total_return=total, annual_return=annual,
        annual_vol=vol, sharpe=sharpe, sortino=sortino,
        max_drawdown=dd, calmar=calmar,
        avg_leverage=float(leverage.mean()),
        max_leverage=float(leverage.max()),
        turnover=turnover)


def simulate_with_sizing(bars: pd.DataFrame, signal: pd.Series,
                         leverage: pd.Series,
                         fee_rate: float = 0.00055,
                         initial_equity: float = 10_000.0
                         ) -> tuple[pd.Series, pd.Series]:
    """シグナルとレバレッジ系列から資産推移を作る。

    signal   : +1/0/−1(前バーまでの情報で決定済み)
    leverage : サイズ倍率(同じく前バーまでの情報)
    """
    ret = bars["close"].pct_change()

    df = pd.DataFrame({
        "ret": ret,
        "signal": signal.shift(1),
        "lev": leverage.shift(1),
    }).dropna()

    position = df["signal"] * df["lev"]
    # ポジション変化に応じた手数料
    turnover = position.diff().abs().fillna(position.abs())
    cost = turnover * fee_rate

    strategy_ret = position * df["ret"] - cost
    equity = (1 + strategy_ret).cumprod() * initial_equity
    return equity, position.abs()


def compare_sizing_methods(bars: pd.DataFrame, signal: pd.Series,
                           target_vol: float = 0.30,
                           fixed_leverage: float = 1.0,
                           kelly_fraction: float = 0.25) -> pd.DataFrame:
    """5方式を同一シグナルで比較する。"""
    from atr_tools import atr
    from sizing_voltarget import vol_target_series
    from vol_estimators import yang_zhang

    close = bars["close"]
    n = len(bars)

    methods: dict[str, pd.Series] = {}

    # 1) 固定レバレッジ
    methods["固定レバレッジ"] = pd.Series(fixed_leverage, index=bars.index)

    # 2) ATRベース(ATR% の逆数でスケール)
    atr_pct = (atr(bars, 14) / close).shift(1)
    atr_lev = (0.01 / atr_pct).clip(0, 3).fillna(0)
    methods["ATRベース"] = atr_lev

    # 3) ボラターゲティング
    methods["ボラターゲット"] = vol_target_series(
        bars, target_vol=target_vol, max_leverage=3.0)

    # 4) ボラターゲット(上限なし)— 上限の重要性を示す
    vol = yang_zhang(bars, 24 * 7, BARS_PER_YEAR).shift(1)
    methods["ボラ目標(上限なし)"] = (target_vol / vol).clip(0, 20).fillna(0)

    # 5) fractional Kelly 相当(シャープ / ボラ)
    ret = close.pct_change()
    rolling_sharpe = (ret.rolling(24 * 30).mean()
                      / ret.rolling(24 * 30).std()).shift(1)
    kelly_lev = (rolling_sharpe * np.sqrt(BARS_PER_YEAR) / vol
                 * kelly_fraction).clip(0, 3).fillna(0)
    methods[f"{kelly_fraction} Kelly"] = kelly_lev

    rows = []
    for name, lev in methods.items():
        equity, actual_lev = simulate_with_sizing(bars, signal, lev)
        if equity.empty:
            continue
        rows.append(evaluate(equity, actual_lev, name))

    return pd.DataFrame([r.__dict__ for r in rows]).set_index("name"), rows


if __name__ == "__main__":
    from client import make_client
    from kline_fetch import fetch_klines

    c = make_client()

    bars = fetch_klines(c.http, "BTCUSDT", interval="60",
                        start="2024-01-01", verbose=False)
    print(f"データ: {len(bars)}本 "
          f"({bars.index[0].date()} 〜 {bars.index[-1].date()})\n")

    # シグナルは全方式で共通(単純な移動平均クロス)
    ma_fast = bars["close"].rolling(24).mean()
    ma_slow = bars["close"].rolling(24 * 7).mean()
    signal = (ma_fast > ma_slow).astype(int) * 2 - 1

    _, stats = compare_sizing_methods(bars, signal)

    print("=== サイジング方式別の成績(シグナルは全て同一) ===\n")
    print(f"{'方式':<18}{'総リターン':>10}{'年率':>10}{'年率ボラ':>9}"
          f"{'シャープ':>8}{'最大DD':>9}{'Calmar':>8}{'平均レバ':>8}")
    print("-" * 82)
    for s in sorted(stats, key=lambda x: -x.calmar):
        print(s.line())

    print("\n【読み方】")
    print("  シグナルは完全に同一。違いはサイズの決め方だけ。")
    print("  それでも最大DDは方式によって大きく変わる。")
    print("  『上限なし』のボラターゲットは、静穏期に過大なレバレッジを")
    print("  取るため、一度の急変で大きく削られる。")
    print("\n  Calmar(年率リターン ÷ 最大DD)で比較するのが実用的。")
    print("  リターンだけを見ると、単にレバレッジが高い方式が勝つ。")

    print("\n※ この比較は手数料のみで、スリッページとファンディングを")
    print("  含んでいない。記事31のバックテスト基盤で再評価すること。")
sizing_log.pyサンプル8
"""sizing_log.py — サイジング決定を記録し、後から検証できるようにする
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  「なぜこのサイズになったのか」を後から追えないと、
  想定外の損失が出たときに原因を特定できない。

  記録すべきもの:
    - いつ・どの銘柄・どの方式で計算したか
    - 入力(資産・ボラ・ATR・勝率など)
    - 理論値と最終値、その間の調整理由
    - 実際に発注したか、その注文ID

  JSONL形式(1行1レコード)にしておくと、
  pandasでそのまま読めて集計しやすい。
"""
from __future__ import annotations

import json
import time
from dataclasses import dataclass, field
from decimal import Decimal
from pathlib import Path

import pandas as pd

from sizing_core import SizingContext, SizingResult

LOG_PATH = Path("./sizing_log.jsonl")


@dataclass
class SizingRecord:
    ts: float
    symbol: str
    side: str
    method: str
    equity: str
    entry_price: str
    stop_price: str
    raw_qty: str
    final_qty: str
    notional: str
    risk_amount: str
    leverage_used: str
    reasons: list[str] = field(default_factory=list)
    inputs: dict = field(default_factory=dict)
    order_id: str = ""
    placed: bool = False

    def to_json(self) -> str:
        return json.dumps(self.__dict__, ensure_ascii=False, default=str)


def record_sizing(ctx: SizingContext, result: SizingResult,
                  order_id: str = "", placed: bool = False,
                  path: Path = LOG_PATH) -> SizingRecord:
    """サイジング結果を記録する。"""
    rec = SizingRecord(
        ts=time.time(),
        symbol=ctx.symbol, side=ctx.side,
        method=result.method.value,
        equity=str(ctx.equity),
        entry_price=str(ctx.entry_price),
        stop_price=str(ctx.stop_price),
        raw_qty=str(result.raw_qty),
        final_qty=str(result.final_qty),
        notional=str(result.notional),
        risk_amount=str(result.risk_amount),
        leverage_used=str(result.leverage_used),
        reasons=list(result.reasons),
        inputs=dict(result.inputs),
        order_id=order_id, placed=placed)

    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as f:
        f.write(rec.to_json() + "\n")
    return rec


def load_records(path: Path = LOG_PATH) -> pd.DataFrame:
    """記録を読み込んでDataFrameにする。"""
    if not path.exists():
        return pd.DataFrame()

    rows = []
    with path.open(encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                rows.append(json.loads(line))
            except json.JSONDecodeError:
                continue

    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    df["datetime"] = pd.to_datetime(df["ts"], unit="s", utc=True)
    for col in ("equity", "raw_qty", "final_qty", "notional",
                "risk_amount", "leverage_used"):
        df[col] = pd.to_numeric(df[col], errors="coerce")
    return df.set_index("datetime").sort_index()


def audit(df: pd.DataFrame) -> None:
    """記録を監査する。想定と実際のズレを洗い出す。"""
    if df.empty:
        print("  記録がありません。")
        return

    print(f"=== サイジング記録の監査 ({len(df)}件) ===\n")

    placed = df[df["placed"]]
    print(f"  発注済み: {len(placed)}件 / 見送り: {len(df) - len(placed)}件")

    if not placed.empty:
        print(f"\n  1件あたりのリスク額:")
        print(f"    平均 {placed['risk_amount'].mean():.2f} USD")
        print(f"    最大 {placed['risk_amount'].max():.2f} USD")
        print(f"    資産比の平均 "
              f"{(placed['risk_amount'] / placed['equity']).mean():.3%}")

        print(f"\n  実効レバレッジ:")
        print(f"    平均 {placed['leverage_used'].mean():.2f}x")
        print(f"    最大 {placed['leverage_used'].max():.2f}x")

    # 理論値からの乖離が大きいものを抽出
    valid = df[df["raw_qty"] > 0].copy()
    if not valid.empty:
        valid["deviation"] = (
            (valid["final_qty"] - valid["raw_qty"]).abs() / valid["raw_qty"])
        large = valid[valid["deviation"] > 0.2]
        if not large.empty:
            print(f"\n  【要確認】理論値から20%以上乖離: {len(large)}件")
            for idx, row in large.head(5).iterrows():
                print(f"    {idx:%Y-%m-%d %H:%M} {row['symbol']:<10}"
                      f"{row['raw_qty']:>12.6f} → {row['final_qty']:>12.6f}"
                      f" ({row['deviation']:.1%})")
                for reason in row["reasons"][:2]:
                    print(f"      └ {reason}")

    # 調整理由の頻度
    all_reasons: dict[str, int] = {}
    for reasons in df["reasons"]:
        for r in reasons:
            key = r.split("(")[0].split("(")[0].strip()
            all_reasons[key] = all_reasons.get(key, 0) + 1

    if all_reasons:
        print(f"\n  調整理由の頻度:")
        for reason, count in sorted(all_reasons.items(),
                                    key=lambda kv: -kv[1])[:8]:
            print(f"    {count:>4}回  {reason}")

    print("\n  方式別の件数:")
    for method, count in df["method"].value_counts().items():
        print(f"    {method:<14}{count:>5}件")


if __name__ == "__main__":
    from sizing_core import size_by_fixed_risk

    # 記録のデモ
    for i, (symbol, price) in enumerate(
            [("BTCUSDT", Decimal("120000")),
             ("ETHUSDT", Decimal("3900")),
             ("SOLUSDT", Decimal("195"))]):
        ctx = SizingContext(
            equity=Decimal("10000"), entry_price=price,
            stop_price=price * Decimal("0.985"), symbol=symbol)
        result = size_by_fixed_risk(ctx, Decimal("0.01"))
        result.final_qty = result.raw_qty      # 本来はサンプル6を通す
        record_sizing(ctx, result, order_id=f"demo-{i}", placed=True)

    df = load_records()
    audit(df)

    print("\n※ 損失が想定を超えたとき、この記録があれば")
    print("  『サイジングの計算が間違っていた』のか")
    print("  『計算は正しかったが相場が想定外だった』のかを切り分けられる。")
    print("  記事39の構造化ログと同じ場所に集約すると扱いやすい。")
参考リンク・文献
  • Get Instruments Info(qtyStep / minOrderQty / maxOrderQty / minNotionalValue): bybit-exchange.github.io/docs/v5/market/instrument
  • Get Wallet Balance: /account/wallet-balance
  • Get Risk Limit: /market/risk-limit
  • Kelly, J.L. (1956) "A New Interpretation of Information Rate", Bell System Technical Journal 35(4)
  • Thorp, E.O. (2006) "The Kelly Criterion in Blackjack, Sports Betting, and the Stock Market", Handbook of Asset and Liability Management
  • MacLean, L., Thorp, E. & Ziemba, W. (2011) The Kelly Capital Growth Investment Criterion, World Scientific
  • Moreira, A. & Muir, T. (2017) "Volatility-Managed Portfolios", Journal of Finance 72(4)
  • Harvey, C. et al. (2018) "The Impact of Volatility Targeting", Journal of Portfolio Management 45(1)
  • Maillard, S., Roncalli, T. & Teiletche, J. (2010) "The Properties of Equally Weighted Risk Contribution Portfolios", Journal of Portfolio Management 36(4)
  • López de Prado, M. (2016) "Building Diversified Portfolios that Outperform Out-of-Sample", Journal of Portfolio Management 42(4)

よくある疑問Q&A

1トレードのリスクは何%が適切ですか
「同時に持つポジション数 × リスク率」で考えてください。1%×10銘柄なら実質10%のリスクを取っています。相関の高い銘柄同士では、実質的に同じ賭けを10回重ねているのと変わりません。
単独ポジションなら0.5〜2%が一般的ですが、絶対的な正解はありません。記事31で自分の戦略のDDを実測し、「そのDDに耐えられるか」から逆算するのが実務的です。
ケリー基準をそのまま使ってはいけませんか
使ってはいけません。ケリーの式は勝率と損益比が正確に分かっている前提です。過去データからの推定値を使うと、推定誤差により f* は必ず過大に出ます。
サンプル4の simulate_kelly_fractions() を実行すると、フルケリーは資産中央値こそ最大でも最大DDと破産率が跳ね上がることが確認できます。実務では 1/4 Kelly 以下が定石で、それでも強気の部類です。
損切りを置かない戦略ではどうサイジングしますか
ボラティリティターゲティング(サンプル3)を使ってください。損切り距離を必要とせず、予測ボラだけでサイズが決まります。
グリッド(記事36)やマーケットメイク(記事37)のように損切りを持たない戦略では、「最大でどこまで在庫を持つか」が実質的なリスク上限になります。その上限をボラターゲットで決めるのが自然です。
計算した数量が最小ロットを下回ります
その取引は見送るのが正解です。最小ロットに切り上げると、想定していたリスク量を超えます。サンプル6の clip_to_constraints() は final_qty=0 を返してこれを防ぎます。
頻繁に発生するなら、資産に対して銘柄の単価が高すぎるかリスク率が小さすぎるかのどちらかです。銘柄を変える(記事12のスクリーナー)か、対象銘柄数を減らして1銘柄あたりのリスクを上げてください。
ボラターゲティングでレバレッジ上限は必要ですか
必須です。ボラが極端に低い局面では target_vol / forecast_vol が10倍を超えることがあり、その直後の急変で壊滅します。
サンプル7の比較で「ボラ目標(上限なし)」の最大DDを見てください。上限ありと比べて明確に悪化します。静穏は急変の前兆であることが多い(ボラのクラスタリング)ため、静穏期に最大レバレッジを取る設計は構造的に危険です。
リスクパリティは戦略が2つでも意味がありますか
2つなら逆ボラウェイト(サンプル5の inverse_vol_weights)で十分です。リスクパリティとほぼ同じ結果になります。
HRPは階層クラスタリングが機能する程度の資産数(目安5以上)が必要で、3以下ではクラスタが作れず意味がありません。共分散行列の推定誤差は資産数が増えるほど深刻になるため、資産数が少ないうちは単純な方式のほうが頑健です。
サイジング方式はどれを選ぶべきですか
損切りがあるなら固定リスク、無いならボラターゲティングが出発点です。ケリーとリスクパリティは、その上で余力があれば検討する層です。
サンプル7の比較で分かる通り、Calmar(年率リターン ÷ 最大DD)で見ると方式による差は明確に出ます。ただし総リターンだけを見ると「単にレバレッジが高い方式」が勝つので、必ずリスク調整後で比較してください。
サイジングの記録は必要ですか
必要です。想定を超える損失が出たとき、「サイジングの計算が間違っていた」のか「計算は正しかったが相場が想定外だった」のかを切り分けられません。
サンプル8の record_sizing() は入力・理論値・最終値・調整理由をすべて残します。audit() で「理論値から20%以上乖離した取引」を洗い出せば、制約による意図しないリスク超過にも気づけます。記事39の構造化ログと同じ場所に集約すると扱いやすくなります。

注意事項・免責事項

投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。

当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。

当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。

当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。

当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。

当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。

当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。