※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
同じシグナル・同じエントリー・同じ損切りでも、ロットの決め方だけで結果は別物になります。期待値がプラスの戦略でも、サイズが大きすぎれば破産します。
| 方式 | 必要な入力 | 長所 | 短所 |
|---|---|---|---|
| 固定枚数 | なし | 最も単純 | 資産が増減してもリスクが変わらない。銘柄間で比較不能 |
| 固定リスク | 損切り距離 | 1トレードの損失額が一定。直感的 | 損切りを置かない戦略には使えない |
| ATRベース | ATR | 銘柄のボラに自動適応 | ATRの係数が恣意的 |
| ボラターゲット | 予測ボラ | ポートフォリオのリスクを一定化。理論的裏付けあり | 売買回数が増えコスト負けしうる |
| ケリー基準 | 勝率・損益比 | 長期成長率を最大化(理論上) | 推定誤差に極端に脆弱。実用は 1/4 以下に縮める |
| リスクパリティ | 共分散行列 | 複数戦略のリスク寄与を均等化 | 共分散の推定誤差。戦略数が少ないと意味が薄い |
期待値を最大化するなら全資産を賭けるのが正解ですが、それでは1回の損失で退場します。実際に最大化すべきは対数資産の期待値(幾何平均成長率)で、これがケリー基準の出発点です。
ただしケリーは勝率と損益比が正確に分かっている前提です。推定値を使うと過大なサイズになるため、実務では 1/4 Kelly 以下に縮めます。この記事の各方式は、最終的にサンプル7の clip_to_constraints() を通して初めて発注可能な数量になります。
最も実装しやすく、最も広く使われる方式です。「1トレードで資産の r% までしか失わない」という制約から数量を逆算します。
"""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(" 結果として小さい損失を何度も繰り返すことになる。")
"""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 — ボラティリティターゲティング
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
参考: 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 を外すと、静穏期に危険なサイズになる。")
ケリー基準は対数資産の期待値(幾何平均成長率)を最大化する賭け率を与えます。理論的には最適ですが、推定誤差に極端に脆弱なので、実務ではそのまま使いません。
"""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 — 逆ボラウェイト・リスクパリティ・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 — 理論数量を発注可能な数量にクリップする
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
適用する制約(この順序が重要):
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(" 『理論値がそのまま通る』ことのほうが稀だと考えること。")
"""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 — サイジング決定を記録し、後から検証できるようにする
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
「なぜこのサイズになったのか」を後から追えないと、
想定外の損失が出たときに原因を特定できない。
記録すべきもの:
- いつ・どの銘柄・どの方式で計算したか
- 入力(資産・ボラ・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の構造化ログと同じ場所に集約すると扱いやすい。")
simulate_kelly_fractions() を実行すると、フルケリーは資産中央値こそ最大でも最大DDと破産率が跳ね上がることが確認できます。実務では 1/4 Kelly 以下が定石で、それでも強気の部類です。clip_to_constraints() は final_qty=0 を返してこれを防ぎます。target_vol / forecast_vol が10倍を超えることがあり、その直後の急変で壊滅します。inverse_vol_weights)で十分です。リスクパリティとほぼ同じ結果になります。record_sizing() は入力・理論値・最終値・調整理由をすべて残します。audit() で「理論値から20%以上乖離した取引」を洗い出せば、制約による意図しないリスク超過にも気づけます。記事39の構造化ログと同じ場所に集約すると扱いやすくなります。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。