|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""분산 대형주 buy&hold의 강건성(robustness) 분석. |
| 3 | +
|
| 4 | +"+132.9%/3년"이 2023-25 강세장 한 번의 운인지, 여러 구간에서 안정적인지 정직하게 본다. |
| 5 | +- 연도별 수익률(2023/2024/2025 각각) |
| 6 | +- 약세장 스트레스(2022 KOSPI 급락장) 포함 구간 |
| 7 | +- 롤링 1년 수익률의 분포(최저/중앙/최고) |
| 8 | +실제 종가 동일비중 보유로만 계산. 벤치마크 트릭 없이 재현 가능. |
| 9 | +
|
| 10 | +Usage: |
| 11 | + python tools/buy_hold_robustness.py |
| 12 | +""" |
| 13 | +import argparse |
| 14 | +import sys |
| 15 | +from pathlib import Path |
| 16 | + |
| 17 | +_ROOT = Path(__file__).resolve().parent.parent |
| 18 | +sys.path.insert(0, str(_ROOT)) |
| 19 | + |
| 20 | +# 분산 대형주 10종목(섹터 다양) — buy&hold 결론의 권장 바스켓과 동일 구성. |
| 21 | +SYMBOLS = ["005930", "000660", "035420", "005380", "051910", |
| 22 | + "005490", "055550", "035720", "012330", "105560"] |
| 23 | + |
| 24 | + |
| 25 | +def _equal_weight_nav(panel, capital=10_000_000, commission=0.00015): |
| 26 | + """패널(컬럼=종목 종가) 첫날 동일비중 매수→보유 NAV 시계열.""" |
| 27 | + import pandas as pd |
| 28 | + first = panel.iloc[0] |
| 29 | + shares, invested, comm = {}, 0.0, 0.0 |
| 30 | + per_name = capital / len(panel.columns) |
| 31 | + for sym in panel.columns: |
| 32 | + qty = int(per_name / first[sym]) |
| 33 | + shares[sym] = qty |
| 34 | + invested += qty * first[sym] |
| 35 | + comm += qty * first[sym] * commission |
| 36 | + cash = capital - invested - comm |
| 37 | + return (panel * pd.Series(shares)).sum(axis=1) + cash |
| 38 | + |
| 39 | + |
| 40 | +def _stats(nav, capital=10_000_000): |
| 41 | + total_ret = (nav.iloc[-1] / capital - 1) * 100 |
| 42 | + dr = nav.pct_change().dropna() |
| 43 | + sharpe = (dr.mean() * 252 - 0.03) / (dr.std() * (252 ** 0.5)) if dr.std() > 0 else 0 |
| 44 | + mdd = ((nav / nav.cummax() - 1) * 100).min() |
| 45 | + return round(total_ret, 1), round(float(sharpe), 2), round(float(mdd), 1) |
| 46 | + |
| 47 | + |
| 48 | +def analyze(capital=10_000_000): |
| 49 | + import warnings, logging |
| 50 | + warnings.filterwarnings("ignore") |
| 51 | + logging.disable(logging.WARNING) |
| 52 | + import pandas as pd |
| 53 | + from core.data_collector import DataCollector |
| 54 | + |
| 55 | + dc = DataCollector() |
| 56 | + closes = {} |
| 57 | + # 2022 약세장 포함 위해 넉넉히 받음 |
| 58 | + for sym in SYMBOLS: |
| 59 | + df = dc.fetch_stock(sym, start_date="2021-12-01", end_date="2025-12-31") |
| 60 | + if df is not None and not df.empty: |
| 61 | + d = df.copy() |
| 62 | + d.index = pd.to_datetime(d["date"]) if "date" in d.columns else pd.to_datetime(d.index) |
| 63 | + closes[sym] = d["close"].astype(float) |
| 64 | + panel_all = pd.DataFrame(closes).dropna(how="any") |
| 65 | + if panel_all.empty: |
| 66 | + raise SystemExit("가격 데이터 없음") |
| 67 | + |
| 68 | + results = {"symbols": len(panel_all.columns), "rows": len(panel_all), |
| 69 | + "data_start": str(panel_all.index[0].date()), "data_end": str(panel_all.index[-1].date())} |
| 70 | + |
| 71 | + # 연도별 + 스트레스 구간 |
| 72 | + windows = { |
| 73 | + "2022 (약세장 스트레스)": ("2022-01-01", "2022-12-31"), |
| 74 | + "2023": ("2023-01-01", "2023-12-31"), |
| 75 | + "2024": ("2024-01-01", "2024-12-31"), |
| 76 | + "2025": ("2025-01-01", "2025-12-31"), |
| 77 | + "2022-2025 전체(약세 포함)": ("2022-01-01", "2025-12-31"), |
| 78 | + "2023-2025 전체(강세만)": ("2023-01-01", "2025-12-31"), |
| 79 | + } |
| 80 | + period_stats = {} |
| 81 | + for label, (s, e) in windows.items(): |
| 82 | + sub = panel_all[(panel_all.index >= pd.Timestamp(s)) & (panel_all.index <= pd.Timestamp(e))] |
| 83 | + if len(sub) < 20: |
| 84 | + period_stats[label] = None |
| 85 | + continue |
| 86 | + nav = _equal_weight_nav(sub, capital) |
| 87 | + period_stats[label] = _stats(nav, capital) |
| 88 | + results["periods"] = period_stats |
| 89 | + |
| 90 | + # 롤링 1년(252거래일) 수익률 분포 |
| 91 | + nav_full = _equal_weight_nav(panel_all, capital) |
| 92 | + roll = [] |
| 93 | + vals = nav_full.values |
| 94 | + for i in range(252, len(vals)): |
| 95 | + roll.append((vals[i] / vals[i - 252] - 1) * 100) |
| 96 | + if roll: |
| 97 | + roll_sorted = sorted(roll) |
| 98 | + n = len(roll_sorted) |
| 99 | + results["rolling_1y"] = { |
| 100 | + "count": n, |
| 101 | + "min": round(roll_sorted[0], 1), |
| 102 | + "p25": round(roll_sorted[n // 4], 1), |
| 103 | + "median": round(roll_sorted[n // 2], 1), |
| 104 | + "max": round(roll_sorted[-1], 1), |
| 105 | + "negative_pct": round(sum(1 for r in roll if r < 0) / n * 100, 1), |
| 106 | + } |
| 107 | + return results |
| 108 | + |
| 109 | + |
| 110 | +def main(): |
| 111 | + p = argparse.ArgumentParser(description="buy&hold 강건성 분석") |
| 112 | + p.add_argument("--capital", type=float, default=10_000_000) |
| 113 | + args = p.parse_args() |
| 114 | + r = analyze(args.capital) |
| 115 | + |
| 116 | + print(f"=== 분산 대형주 buy&hold 강건성 분석 ===") |
| 117 | + print(f"데이터: {r['symbols']}종목, {r['data_start']} ~ {r['data_end']} ({r['rows']} 거래일)") |
| 118 | + print(f"\n구간별 (총수익% / Sharpe / MDD%):") |
| 119 | + for label, st in r["periods"].items(): |
| 120 | + if st is None: |
| 121 | + print(f" {label:28} 데이터 부족") |
| 122 | + else: |
| 123 | + print(f" {label:28} {st[0]:+7.1f}% Sharpe {st[1]:+.2f} MDD {st[2]:.1f}%") |
| 124 | + rl = r.get("rolling_1y") |
| 125 | + if rl: |
| 126 | + print(f"\n롤링 1년 수익률 분포({rl['count']}개 관측):") |
| 127 | + print(f" 최저 {rl['min']:+.1f}% | 25%분위 {rl['p25']:+.1f}% | 중앙 {rl['median']:+.1f}% | 최고 {rl['max']:+.1f}%") |
| 128 | + print(f" 1년 보유 시 손실 확률: {rl['negative_pct']}%") |
| 129 | + return r |
| 130 | + |
| 131 | + |
| 132 | +if __name__ == "__main__": |
| 133 | + main() |
0 commit comments