Files
cointrader/scripts/position_sizing_analysis.py
21in7 5b3f6af13c feat: add symbol comparison and position sizing analysis tools
- Add payoff_ratio and max_consecutive_losses to backtester summary
- Add compare_symbols.py: per-symbol parameter sweep for candidate evaluation
- Add position_sizing_analysis.py: robust Monte Carlo position sizing
- Fetch historical data for SOL, LINK, AVAX candidates (365 days)
- Update existing symbol data (XRP, TRX, DOGE)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:35:42 +09:00

322 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
포지션 사이징 분석: Robust Monte Carlo 방식.
핵심: 백테스트 31건의 승률/손익비를 고정값으로 믿지 않고,
불확실성 범위(승률 30~45%, 손익비 3.0~5.0)를 넣어
worst-case 조합에서도 파산하지 않는 리스크 비중을 산출한다.
사용법:
python scripts/position_sizing_analysis.py --symbol SOLUSDT
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import numpy as np
from loguru import logger
from src.backtester import WalkForwardBacktester, WalkForwardConfig
def run_backtest(symbol: str, params: dict) -> dict:
cfg = WalkForwardConfig(
symbols=[symbol],
use_ml=False,
train_months=3,
test_months=1,
**params,
)
wf = WalkForwardBacktester(cfg)
return wf.run()
def extract_r_multiples(trades: list[dict]) -> np.ndarray:
"""각 트레이드의 R-multiple을 추출 (1R = SL 히트 시 손실)."""
r_multiples = []
for t in trades:
sl_distance = abs(t["entry_price"] - t["sl"])
sl_loss = sl_distance * t["quantity"]
if sl_loss <= 0:
continue
r_multiples.append(t["net_pnl"] / sl_loss)
return np.array(r_multiples)
def kelly_criterion(win_rate: float, avg_win_r: float, avg_loss_r: float) -> float:
"""Kelly: f* = (W * avg_win_R - (1-W) * |avg_loss_R|) / avg_win_R"""
if avg_win_r <= 0:
return 0.0
expectancy = win_rate * avg_win_r - (1 - win_rate) * abs(avg_loss_r)
if expectancy <= 0:
return 0.0
return expectancy / avg_win_r
def consecutive_loss_survival(risk_pct: float, n: int) -> float:
"""n연패 후 잔고 비율(%)."""
return (1 - risk_pct) ** n * 100
def robust_monte_carlo(
risk_pct: float,
win_rate_range: tuple[float, float],
payoff_range: tuple[float, float],
loss_r_range: tuple[float, float],
n_simulations: int = 10000,
n_trades: int = 200,
initial_balance: float = 1000.0,
ruin_threshold: float = 0.20,
) -> dict:
"""Robust Monte Carlo: 매 시뮬레이션마다 승률/손익비를 범위 내에서 샘플링.
각 시뮬레이션:
1) 승률을 win_rate_range에서 uniform 추출
2) 승리 R-multiple을 payoff_range에서 uniform 추출
3) 패배 R-multiple을 loss_r_range에서 uniform 추출
4) 해당 파라미터로 n_trades건을 생성하여 에퀴티 시뮬레이션
"""
rng = np.random.default_rng(42)
final_balances = np.zeros(n_simulations)
max_drawdowns = np.zeros(n_simulations)
ruin_count = 0
for sim in range(n_simulations):
# 파라미터 샘플링
wr = rng.uniform(*win_rate_range)
win_r = rng.uniform(*payoff_range)
loss_r = rng.uniform(*loss_r_range)
# 트레이드 생성
outcomes = rng.random(n_trades)
r_multiples = np.where(outcomes < wr, win_r, loss_r)
# 에퀴티 시뮬레이션
balance = initial_balance
peak = balance
max_dd = 0.0
ruined = False
for r in r_multiples:
pnl = balance * risk_pct * r
balance += pnl
if balance <= initial_balance * ruin_threshold:
ruined = True
break
peak = max(peak, balance)
dd = (peak - balance) / peak
max_dd = max(max_dd, dd)
if ruined:
ruin_count += 1
final_balances[sim] = 0
max_drawdowns[sim] = 1.0
else:
final_balances[sim] = balance
max_drawdowns[sim] = max_dd
return {
"risk_pct": risk_pct,
"ruin_probability": round(ruin_count / n_simulations * 100, 2),
"median_return": round((np.median(final_balances) - initial_balance) / initial_balance * 100, 1),
"p5_return": round((np.percentile(final_balances, 5) - initial_balance) / initial_balance * 100, 1),
"p25_return": round((np.percentile(final_balances, 25) - initial_balance) / initial_balance * 100, 1),
"p75_return": round((np.percentile(final_balances, 75) - initial_balance) / initial_balance * 100, 1),
"p95_return": round((np.percentile(final_balances, 95) - initial_balance) / initial_balance * 100, 1),
"median_max_dd": round(np.median(max_drawdowns) * 100, 1),
"p95_max_dd": round(np.percentile(max_drawdowns, 95) * 100, 1),
}
def main():
parser = argparse.ArgumentParser(description="포지션 사이징 분석 (Robust Monte Carlo)")
parser.add_argument("--symbol", required=True, type=str)
parser.add_argument("--sl-mult", type=float, default=1.0)
parser.add_argument("--tp-mult", type=float, default=4.0)
parser.add_argument("--signal-threshold", type=int, default=3)
parser.add_argument("--adx", type=float, default=20)
parser.add_argument("--vol-mult", type=float, default=2.5)
args = parser.parse_args()
symbol = args.symbol.upper()
params = {
"atr_sl_mult": args.sl_mult,
"atr_tp_mult": args.tp_mult,
"signal_threshold": args.signal_threshold,
"adx_threshold": args.adx,
"volume_multiplier": args.vol_mult,
}
# 1) 백테스트로 기준값 추출
logger.info(f"[{symbol}] 백테스트 실행")
result = run_backtest(symbol, params)
trades = result.get("trades", [])
summary = result["summary"]
if len(trades) < 5:
logger.error(f"트레이드 {len(trades)}건 — 분석 불가")
return
r_multiples = extract_r_multiples(trades)
wins = r_multiples[r_multiples > 0]
losses = r_multiples[r_multiples <= 0]
obs_wr = len(wins) / len(r_multiples)
obs_win_r = float(np.mean(wins)) if len(wins) > 0 else 0
obs_loss_r = float(np.mean(losses)) if len(losses) > 0 else 0
obs_expectancy = obs_wr * obs_win_r + (1 - obs_wr) * obs_loss_r
obs_kelly = kelly_criterion(obs_wr, obs_win_r, abs(obs_loss_r))
# 불확실성 범위 설정 (관측값 기준 ±보정)
# 승률: 관측 38.7% → 30~45% (하방으로 더 넓게)
wr_lo = max(0.25, obs_wr - 0.10)
wr_hi = min(0.55, obs_wr + 0.07)
# 승리 R: 관측 3.85R → 3.0~5.0
win_r_lo = max(2.0, obs_win_r - 1.0)
win_r_hi = obs_win_r + 1.2
# 패배 R: 관측 -1.18R → -1.5 ~ -0.9
loss_r_lo = min(-0.8, obs_loss_r + 0.3)
loss_r_hi = obs_loss_r - 0.3
print(f"\n{'=' * 85}")
print(f" 포지션 사이징 분석: {symbol} (Robust Monte Carlo)")
print(f" 파라미터: SL={args.sl_mult}x ATR, TP={args.tp_mult}x ATR, ADX={args.adx}")
print(f"{'=' * 85}")
# 관측값
print(f"\n[백테스트 관측값] ({len(trades)}건)")
print(f" 승률: {obs_wr*100:.1f}% | 승리 평균: +{obs_win_r:.2f}R | 패배 평균: {obs_loss_r:.2f}R")
print(f" 기대값: {obs_expectancy:.2f}R | Kelly: {obs_kelly*100:.1f}%")
print(f" 최대 연속 손실: {summary.get('max_consecutive_losses', 'N/A')}")
# R-multiple 분포 (간략)
print(f"\n R 분포: 패배 {obs_loss_r:.2f}R (SL+수수료) | 승리 +{obs_win_r:.2f}R (TP-수수료)")
print(f" → 거의 바이너리: SL 아니면 TP, 중간 청산 없음")
# 불확실성 범위
print(f"\n[불확실성 범위] (실전 괴리 반영)")
print(f" 승률: {wr_lo*100:.0f}% ~ {wr_hi*100:.0f}% (관측: {obs_wr*100:.1f}%)")
print(f" 승리 R: +{win_r_lo:.1f}R ~ +{win_r_hi:.1f}R (관측: +{obs_win_r:.2f}R)")
print(f" 패배 R: {loss_r_hi:.1f}R ~ {loss_r_lo:.1f}R (관측: {obs_loss_r:.2f}R)")
# Worst-case Kelly
worst_kelly = kelly_criterion(wr_lo, win_r_lo, abs(loss_r_hi))
best_kelly = kelly_criterion(wr_hi, win_r_hi, abs(loss_r_lo))
print(f"\n Worst-case Kelly: {worst_kelly*100:.1f}% | Best-case Kelly: {best_kelly*100:.1f}%")
# 연속 손실 생존 테이블
print(f"\n[연속 손실 생존 테이블]")
print(f" {'리스크%':>8} {'4연패':>7} {'6연패':>7} {'8연패':>7} {'10연패':>7} {'12연패':>7}")
print(f" {'-' * 50}")
for rp in [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05]:
cols = [f"{consecutive_loss_survival(rp, n):.1f}%" for n in [4, 6, 8, 10, 12]]
print(f" {rp*100:>7.1f}% {' '.join(f'{c:>7}' for c in cols)}")
# Robust Monte Carlo
risk_levels = [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05, 0.07]
print(f"\n[Robust Monte Carlo (10,000회 × 200건, 파라미터 매회 랜덤 샘플링)]")
print(f" 파산 기준: 잔고 ≤ 20%")
print(f" {'리스크%':>8} {'파산%':>6} {'하위5%':>9} {'하위25%':>9} {'중위':>9} "
f"{'상위75%':>9} {'상위95%':>10} {'중위MDD':>7} {'95%MDD':>7}")
print(f" {'-' * 85}")
best_risk = None
best_score = -999
mc_results = []
for rp in risk_levels:
mc = robust_monte_carlo(
risk_pct=rp,
win_rate_range=(wr_lo, wr_hi),
payoff_range=(win_r_lo, win_r_hi),
loss_r_range=(loss_r_hi, loss_r_lo), # hi is more negative
)
mc_results.append(mc)
# 숫자 포맷
def fmt_ret(v):
if abs(v) >= 10000:
return f"{v/1000:>+7.0f}k%"
return f"{v:>+8.1f}%"
print(f" {rp*100:>7.1f}% {mc['ruin_probability']:>5.1f}% "
f"{fmt_ret(mc['p5_return'])} {fmt_ret(mc['p25_return'])} "
f"{fmt_ret(mc['median_return'])} {fmt_ret(mc['p75_return'])} "
f"{fmt_ret(mc['p95_return']):>10} {mc['median_max_dd']:>6.1f}% "
f"{mc['p95_max_dd']:>6.1f}%")
# 선정 기준: 파산 <1% AND 95%MDD ≤ 30% 에서 중위 수익 최대
if (mc["ruin_probability"] <= 1.0
and mc["p95_max_dd"] <= 30.0
and mc["median_return"] > best_score):
best_score = mc["median_return"]
best_risk = rp
# Worst-case 전용 MC (승률 30%, 손익비 3.0 고정)
print(f"\n[Worst-Case 시나리오 (승률={wr_lo*100:.0f}%, 승리R=+{win_r_lo:.1f}, 패배R={loss_r_hi:.1f})]")
print(f" {'리스크%':>8} {'파산%':>6} {'중위수익':>9} {'95%MDD':>7}")
print(f" {'-' * 35}")
worst_best_risk = None
worst_best_score = -999
for rp in risk_levels:
mc = robust_monte_carlo(
risk_pct=rp,
win_rate_range=(wr_lo, wr_lo + 0.001), # 거의 고정
payoff_range=(win_r_lo, win_r_lo + 0.001),
loss_r_range=(loss_r_hi, loss_r_hi + 0.001),
)
def fmt_ret(v):
if abs(v) >= 10000:
return f"{v/1000:>+7.0f}k%"
return f"{v:>+8.1f}%"
print(f" {rp*100:>7.1f}% {mc['ruin_probability']:>5.1f}% "
f"{fmt_ret(mc['median_return'])} {mc['p95_max_dd']:>6.1f}%")
if (mc["ruin_probability"] <= 1.0
and mc["p95_max_dd"] <= 30.0
and mc["median_return"] > worst_best_score):
worst_best_score = mc["median_return"]
worst_best_risk = rp
# 최종 권장
print(f"\n{'=' * 85}")
print(f" 최종 권장")
print(f"{'=' * 85}")
# 가장 보수적인 값: worst-case MC 최적과 robust MC 최적 중 작은 값
candidates = [r for r in [best_risk, worst_best_risk, worst_kelly / 2] if r and r > 0]
recommended = min(candidates) if candidates else 0.01
recommended = max(0.005, min(recommended, 0.05))
print(f" Robust MC 최적 (파산<1%, 95%MDD≤30%): {best_risk*100:.1f}%" if best_risk else " Robust MC: 조건 충족 없음")
print(f" Worst-Case MC 최적: {worst_best_risk*100:.1f}%" if worst_best_risk else " Worst-Case MC: 조건 충족 없음")
print(f" Worst-Case Half Kelly: {worst_kelly/2*100:.1f}%")
print(f"\n >>> 실전 권장: 1회 리스크 = 계좌의 {recommended*100:.1f}%")
print(f" 근거: worst-case에서도 파산하지 않는 가장 보수적 기준")
survival_6 = consecutive_loss_survival(recommended, 6)
survival_10 = consecutive_loss_survival(recommended, 10)
print(f" 6연패 후 잔고: {survival_6:.1f}% | 10연패 후: {survival_10:.1f}%")
print(f"\n [.env 설정 가이드]")
print(f" ATR_SL_MULT_SOLUSDT={args.sl_mult}")
print(f" ATR_TP_MULT_SOLUSDT={args.tp_mult}")
print(f" ADX_THRESHOLD_SOLUSDT={args.adx}")
print(f" SIGNAL_THRESHOLD_SOLUSDT={args.signal_threshold}")
for atr_pct in [0.01, 0.012, 0.015]:
margin_ratio = recommended / (10 * atr_pct)
margin_ratio = min(margin_ratio, 0.50)
print(f" ATR≈{atr_pct*100:.1f}% → MARGIN_MAX_RATIO_SOLUSDT ≈ {margin_ratio:.2f}")
print()
if __name__ == "__main__":
main()