diff --git a/data/avaxusdt/combined_15m.parquet b/data/avaxusdt/combined_15m.parquet new file mode 100644 index 0000000..271340c Binary files /dev/null and b/data/avaxusdt/combined_15m.parquet differ diff --git a/data/dogeusdt/combined_15m.parquet b/data/dogeusdt/combined_15m.parquet index a407383..da712da 100644 Binary files a/data/dogeusdt/combined_15m.parquet and b/data/dogeusdt/combined_15m.parquet differ diff --git a/data/linkusdt/combined_15m.parquet b/data/linkusdt/combined_15m.parquet new file mode 100644 index 0000000..be343bf Binary files /dev/null and b/data/linkusdt/combined_15m.parquet differ diff --git a/data/solusdt/combined_15m.parquet b/data/solusdt/combined_15m.parquet new file mode 100644 index 0000000..837b06e Binary files /dev/null and b/data/solusdt/combined_15m.parquet differ diff --git a/data/trxusdt/combined_15m.parquet b/data/trxusdt/combined_15m.parquet index 5cd175d..d717d53 100644 Binary files a/data/trxusdt/combined_15m.parquet and b/data/trxusdt/combined_15m.parquet differ diff --git a/data/xrpusdt/combined_15m.parquet b/data/xrpusdt/combined_15m.parquet index 182a534..c27b32f 100644 Binary files a/data/xrpusdt/combined_15m.parquet and b/data/xrpusdt/combined_15m.parquet differ diff --git a/results/compare/compare_2026-03-18.json b/results/compare/compare_2026-03-18.json new file mode 100644 index 0000000..8a737a3 --- /dev/null +++ b/results/compare/compare_2026-03-18.json @@ -0,0 +1,86 @@ +[ + { + "symbol": "SOLUSDT", + "best_params": { + "atr_sl_mult": 1.0, + "atr_tp_mult": 4.0, + "signal_threshold": 3, + "adx_threshold": 20, + "volume_multiplier": 2.5 + }, + "summary": { + "total_trades": 31, + "total_pnl": 909.294, + "return_pct": 90.93, + "win_rate": 38.71, + "avg_win": 117.2904, + "avg_loss": -26.2206, + "payoff_ratio": 4.47, + "max_consecutive_losses": 6, + "profit_factor": 2.83, + "max_drawdown_pct": 10.87, + "sharpe_ratio": 57.43, + "total_fees": 117.2484, + "close_reasons": { + "TAKE_PROFIT": 12, + "STOP_LOSS": 19 + } + } + }, + { + "symbol": "LINKUSDT", + "best_params": { + "atr_sl_mult": 2.0, + "atr_tp_mult": 3.0, + "signal_threshold": 3, + "adx_threshold": 0, + "volume_multiplier": 2.5 + }, + "summary": { + "total_trades": 38, + "total_pnl": 12.3248, + "return_pct": 1.23, + "win_rate": 39.47, + "avg_win": 88.1543, + "avg_loss": -56.9561, + "payoff_ratio": 1.55, + "max_consecutive_losses": 5, + "profit_factor": 1.01, + "max_drawdown_pct": 24.28, + "sharpe_ratio": 0.67, + "total_fees": 142.4705, + "close_reasons": { + "TAKE_PROFIT": 15, + "STOP_LOSS": 23 + } + } + }, + { + "symbol": "AVAXUSDT", + "best_params": { + "atr_sl_mult": 1.5, + "atr_tp_mult": 3.0, + "signal_threshold": 3, + "adx_threshold": 25, + "volume_multiplier": 2.5 + }, + "summary": { + "total_trades": 20, + "total_pnl": 497.5511, + "return_pct": 49.76, + "win_rate": 55.0, + "avg_win": 90.6485, + "avg_loss": -55.5092, + "payoff_ratio": 1.63, + "max_consecutive_losses": 3, + "profit_factor": 2.0, + "max_drawdown_pct": 8.89, + "sharpe_ratio": 47.39, + "total_fees": 76.184, + "close_reasons": { + "STOP_LOSS": 9, + "TAKE_PROFIT": 11 + } + } + } +] \ No newline at end of file diff --git a/scripts/compare_symbols.py b/scripts/compare_symbols.py new file mode 100644 index 0000000..6a5f980 --- /dev/null +++ b/scripts/compare_symbols.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +종목 비교 백테스트: 후보 심볼별 파라미터 sweep → 최적 파라미터 기준 비교표 출력. + +사용법: + python scripts/compare_symbols.py --symbols SOLUSDT LINKUSDT AVAXUSDT + python scripts/compare_symbols.py --symbols SOLUSDT LINKUSDT AVAXUSDT --skip-fetch +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import argparse +import json +import subprocess +from datetime import date + +from loguru import logger + +from src.backtester import WalkForwardBacktester, WalkForwardConfig +from scripts.strategy_sweep import generate_combinations, PARAM_GRID + + +TRAIN_MONTHS = 3 +TEST_MONTHS = 1 +FETCH_DAYS = 365 + + +def fetch_data(symbols: list[str], days: int = FETCH_DAYS) -> None: + script = str(Path(__file__).parent / "fetch_history.py") + for sym in symbols: + cmd = [ + sys.executable, script, + "--symbol", sym, + "--interval", "15m", + "--days", str(days), + ] + logger.info(f"데이터 수집: {sym} (최근 {days}일)") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + logger.error(f" {sym} 수집 실패: {result.stderr[:300]}") + else: + logger.info(f" {sym} 수집 완료") + + +def run_backtest(symbol: str, params: dict) -> dict: + cfg = WalkForwardConfig( + symbols=[symbol], + use_ml=False, + train_months=TRAIN_MONTHS, + test_months=TEST_MONTHS, + **params, + ) + wf = WalkForwardBacktester(cfg) + return wf.run() + + +def sweep_symbol(symbol: str) -> dict: + """심볼별 파라미터 sweep 실행 → 최적 조합 반환.""" + combos = generate_combinations(PARAM_GRID) + logger.info(f"[{symbol}] 파라미터 sweep 시작: {len(combos)}개 조합") + + best = None + best_params = None + + for i, params in enumerate(combos): + try: + result = run_backtest(symbol, params) + summary = result["summary"] + + # 거래 5건 미만은 스킵 + if summary["total_trades"] < 5: + continue + + # PF 기준으로 최적 선택 (동률 시 승률 → 손익비 순) + if best is None or _is_better(summary, best): + best = summary + best_params = params + + except Exception as e: + logger.warning(f" [{symbol}] 조합 {i+1} 실패: {e}") + + if (i + 1) % 50 == 0: + logger.info(f" [{symbol}] {i+1}/{len(combos)} 완료") + + logger.info(f"[{symbol}] sweep 완료 → 최적 PF: {best['profit_factor'] if best else 'N/A'}") + return {"symbol": symbol, "best_params": best_params, "summary": best} + + +def _is_better(new: dict, old: dict) -> bool: + """PF → 손익비 → 승률 순으로 비교.""" + new_pf = new["profit_factor"] if new["profit_factor"] != float("inf") else 999 + old_pf = old["profit_factor"] if old["profit_factor"] != float("inf") else 999 + + if new_pf != old_pf: + return new_pf > old_pf + new_pr = new.get("payoff_ratio", 0) or 0 + old_pr = old.get("payoff_ratio", 0) or 0 + if new_pr != old_pr: + return new_pr > old_pr + return new["win_rate"] > old["win_rate"] + + +def print_comparison(results: list[dict]) -> None: + header = ( + f"{'심볼':<10} {'파라미터':^30} {'거래수':>6} {'승률':>7} " + f"{'손익비':>7} {'연속손실':>8} {'PF':>6} {'수익률':>8} {'MDD':>6} {'총PnL':>10}" + ) + sep = "=" * len(header) + print(f"\n{sep}") + print("종목 비교 백테스트 결과 (심볼별 최적 파라미터)") + print(sep) + print(header) + print("-" * len(header)) + + for r in results: + s = r["summary"] + p = r["best_params"] + if not s or not p: + print(f"{r['symbol'].replace('USDT', ''):<10} {'데이터 부족 또는 sweep 실패':^30}") + continue + + short = r["symbol"].replace("USDT", "") + param_str = f"SL={p['atr_sl_mult']}/TP={p['atr_tp_mult']}/ADX={p['adx_threshold']}" + pf = s["profit_factor"] + pf_str = f"{pf:.2f}" if pf != float("inf") else "INF" + + print( + f"{short:<10} {param_str:^30} {s['total_trades']:>6} " + f"{s['win_rate']:>6.1f}% " + f"{s.get('payoff_ratio', 0):>7.2f} " + f"{s.get('max_consecutive_losses', 0):>8} " + f"{pf_str:>6} " + f"{s['return_pct']:>7.2f}% " + f"{s['max_drawdown_pct']:>5.1f}% " + f"{s['total_pnl']:>+10.2f}" + ) + + print("-" * len(header)) + print("\n[판정 기준]") + print(" - 승률 50%+ & 손익비 1.0+ → 실전 지속 가능") + print(" - 연속 손실 5회 이하 → 멘탈 관리 가능") + print(" - 거래 20건+ → 통계적 유의성 있음") + print() + + # 상세 파라미터 출력 + print("[심볼별 최적 파라미터 상세]") + for r in results: + if r["best_params"]: + p = r["best_params"] + print(f" {r['symbol']}: SL={p['atr_sl_mult']}, TP={p['atr_tp_mult']}, " + f"Signal={p['signal_threshold']}, ADX={p['adx_threshold']}, " + f"Vol={p['volume_multiplier']}") + print() + + +def main(): + parser = argparse.ArgumentParser(description="종목 비교 백테스트 (심볼별 파라미터 sweep)") + parser.add_argument( + "--symbols", nargs="+", required=True, + help="비교할 심볼 리스트 (e.g., SOLUSDT LINKUSDT AVAXUSDT)", + ) + parser.add_argument("--skip-fetch", action="store_true", help="데이터 수집 스킵") + parser.add_argument("--days", type=int, default=FETCH_DAYS, help="데이터 수집 기간 (일)") + args = parser.parse_args() + + # 1) 데이터 수집 + if not args.skip_fetch: + fetch_data(args.symbols, args.days) + + # 2) 심볼별 sweep + results = [] + for sym in args.symbols: + try: + result = sweep_symbol(sym) + results.append(result) + except Exception as e: + logger.error(f" {sym} sweep 실패: {e}") + results.append({"symbol": sym, "best_params": None, "summary": None}) + + # 3) 비교표 + if results: + print_comparison(results) + + # 4) JSON 저장 + out_dir = Path("results/compare") + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / f"compare_{date.today().isoformat()}.json" + with open(out_path, "w") as f: + json.dump( + [{ + "symbol": r["symbol"], + "best_params": r["best_params"], + "summary": r["summary"], + } for r in results], + f, indent=2, ensure_ascii=False, + default=lambda x: str(x) if isinstance(x, float) and x == float("inf") else x, + ) + logger.info(f"결과 저장: {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/position_sizing_analysis.py b/scripts/position_sizing_analysis.py new file mode 100644 index 0000000..3503551 --- /dev/null +++ b/scripts/position_sizing_analysis.py @@ -0,0 +1,321 @@ +#!/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() diff --git a/src/backtester.py b/src/backtester.py index 4efe4f7..ff46e9d 100644 --- a/src/backtester.py +++ b/src/backtester.py @@ -562,6 +562,21 @@ class Backtester: else: sharpe = 0.0 + # 손익비 (avg_win / |avg_loss|) + avg_w = float(np.mean(wins)) if wins else 0.0 + avg_l = float(np.mean(losses)) if losses else 0.0 + payoff_ratio = round(avg_w / abs(avg_l), 2) if avg_l != 0 else float("inf") + + # 최대 연속 손실 횟수 + max_consec_loss = 0 + cur_streak = 0 + for p in pnls: + if p <= 0: + cur_streak += 1 + max_consec_loss = max(max_consec_loss, cur_streak) + else: + cur_streak = 0 + # 청산 사유별 비율 reasons = {} for t in self.trades: @@ -573,8 +588,10 @@ class Backtester: "total_pnl": round(total_pnl, 4), "return_pct": round(total_pnl / self.cfg.initial_balance * 100, 2), "win_rate": round(len(wins) / len(self.trades) * 100, 2) if self.trades else 0.0, - "avg_win": round(np.mean(wins), 4) if wins else 0.0, - "avg_loss": round(np.mean(losses), 4) if losses else 0.0, + "avg_win": round(avg_w, 4), + "avg_loss": round(avg_l, 4), + "payoff_ratio": payoff_ratio, + "max_consecutive_losses": max_consec_loss, "profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"), "max_drawdown_pct": round(mdd, 2), "sharpe_ratio": round(sharpe, 2), @@ -797,6 +814,7 @@ class WalkForwardBacktester: if not all_trades: summary = {"total_trades": 0, "total_pnl": 0.0, "return_pct": 0.0, "win_rate": 0.0, "avg_win": 0.0, "avg_loss": 0.0, + "payoff_ratio": 0.0, "max_consecutive_losses": 0, "profit_factor": 0.0, "max_drawdown_pct": 0.0, "sharpe_ratio": 0.0, "total_fees": 0.0, "close_reasons": {}} else: @@ -820,6 +838,21 @@ class WalkForwardBacktester: else: sharpe = 0.0 + # 손익비 (avg_win / |avg_loss|) + avg_w = float(np.mean(wins)) if wins else 0.0 + avg_l = float(np.mean(losses)) if losses else 0.0 + payoff_ratio = round(avg_w / abs(avg_l), 2) if avg_l != 0 else float("inf") + + # 최대 연속 손실 횟수 + max_consec_loss = 0 + cur_streak = 0 + for p in pnls: + if p <= 0: + cur_streak += 1 + max_consec_loss = max(max_consec_loss, cur_streak) + else: + cur_streak = 0 + reasons = {} for t in all_trades: r = t["close_reason"] @@ -830,8 +863,10 @@ class WalkForwardBacktester: "total_pnl": round(total_pnl, 4), "return_pct": round(total_pnl / self.cfg.initial_balance * 100, 2), "win_rate": round(len(wins) / len(all_trades) * 100, 2), - "avg_win": round(np.mean(wins), 4) if wins else 0.0, - "avg_loss": round(np.mean(losses), 4) if losses else 0.0, + "avg_win": round(avg_w, 4), + "avg_loss": round(avg_l, 4), + "payoff_ratio": payoff_ratio, + "max_consecutive_losses": max_consec_loss, "profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"), "max_drawdown_pct": round(mdd, 2), "sharpe_ratio": round(sharpe, 2),