Skip to content

Commit 50f2eb4

Browse files
authored
Merge pull request #388 from easygap/analysis/buy-hold-robustness
buy&hold 강건성 분석 — 헤드라인 +132.9%는 안정적이 아님(정직 점검)
2 parents 5730c47 + 6d700dc commit 50f2eb4

3 files changed

Lines changed: 256 additions & 0 deletions

File tree

docs/BUY_HOLD_ROBUSTNESS.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# buy&hold 강건성 분석 — "안정적"인가? (정직한 점검)
2+
3+
> 목적: "+132.9%/3년"이 **안정적 고수익**인지, 아니면 시작 시점·강세장 운인지 정직하게 본다.
4+
> 실제 종가 동일비중 보유로만 계산(`tools/buy_hold_robustness.py`로 재현). 2022 약세장 포함.
5+
> 작성: 2026-06-01.
6+
7+
## 결론 한 줄
8+
9+
**buy&hold는 "고수익"이긴 해도 "안정적"이지는 않다.** 장기 보유하면 좋은 누적수익을 주지만,
10+
연 단위로 보면 손실 나는 해가 절반이고 −25% 낙폭을 겪는다. "안정적으로 높은 수익"이라는
11+
목표 기준으로는 **연도 변동성·낙폭을 반드시 감안**해야 한다.
12+
13+
## 구간별 성과 (분산 대형주 10종목, 비용 반영)
14+
15+
| 구간 | 총수익 | Sharpe | MDD |
16+
|------|--------|--------|-----|
17+
| **2022 (약세장 스트레스)** | **−21.5%** | −1.53 | −25.5% |
18+
| 2023 | +28.2% | +1.35 | −14.9% |
19+
| **2024** | **−7.2%** | −0.37 | −16.2% |
20+
| 2025 | +68.8% | +2.29 | −16.1% |
21+
| 2022–2025 전체(약세 포함) | **+62.1%** | **0.58** | −25.5% |
22+
| 2023–2025 전체(강세만) | +132.9% | 1.29 | −23.2% |
23+
24+
## 정직한 해석
25+
26+
1. **헤드라인 +132.9%는 시작 시점에 민감하다.** 2022(약세장)를 포함하면 +62.1%로 떨어진다
27+
(4년 CAGR ≈ 12.9%, 3년 강세장 CAGR 32.6%의 절반 이하). 시작점을 강세장 직전으로 잡았기 때문.
28+
2. **4년 중 2년이 마이너스다**(2022 −21.5%, 2024 −7.2%). 즉 "매년 안정적"이 아니다.
29+
3. **2025년 +68.8%(Sharpe 2.29)가 헤드라인을 끌어올린 이상치**다. 이 한 해를 빼면 그림이 크게 달라진다.
30+
4. **롤링 1년 수익률**(747개 관측): 최저 −23.2%, 중앙 +10.6%, 최고 +74.0%. **임의 시점에 1년 보유
31+
시 손실 확률 26.5%.**
32+
33+
## 그래서 "안정적 고수익"을 위해 필요한 것
34+
35+
- **기대치 현실화**: 장기 CAGR ~13%(약세 포함)가 현실적 기대값이고, +30%대는 강세장 한정이다.
36+
- **낙폭 관리가 핵심**: −25% 낙폭과 손실 연도를 견딜 수 있는 자본 배분·기간이 전제다. 단기 자금이면
37+
부적합하다.
38+
- **변동성을 줄이려는 능동 시도는 앞서 전부 실패**(모멘텀/저변동/risk-parity/SMA risk-off가 EW B&H를
39+
못 이김 — `docs/PROFITABILITY_FINDINGS.md`). 즉 "낮은 변동성 + 같은 수익"의 손쉬운 길은 없다.
40+
- **결론**: 이 시스템으로 얻을 수 있는 정직한 그림은 "장기 분산 보유로 연 ~13%(변동성·손실연도 포함)"
41+
이며, "안정적으로 매년 높은 수익"은 데이터가 지지하지 않는다. 실거래 결정 시 이 변동성을 전제로 할 것.
42+
43+
재현: `python tools/buy_hold_robustness.py`

tests/test_buy_hold_robustness.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""buy&hold 강건성 분석기(tools/buy_hold_robustness.py) 단위 테스트.
2+
3+
실데이터 fetch는 모킹하고, NAV/통계/구간 분해 로직만 결정론적으로 검증한다.
4+
"""
5+
from unittest.mock import patch
6+
7+
import pandas as pd
8+
import pytest
9+
10+
11+
def _fake_collector(panel: pd.DataFrame):
12+
class _DC:
13+
def fetch_stock(self, sym, start_date=None, end_date=None):
14+
if sym not in panel.columns:
15+
return None
16+
return pd.DataFrame({"date": panel.index, "close": panel[sym].values})
17+
return _DC()
18+
19+
20+
def test_equal_weight_nav_growth():
21+
import tools.buy_hold_robustness as bhr
22+
23+
dates = pd.date_range("2023-01-01", periods=100, freq="D")
24+
# 두 종목 모두 +50% 선형 상승 → NAV도 ~+50%
25+
a = pd.Series([100 + 50 * i / 99 for i in range(100)], index=dates)
26+
panel = pd.DataFrame({"AAA": a, "BBB": a.copy()})
27+
nav = bhr._equal_weight_nav(panel, capital=10_000_000)
28+
assert nav.iloc[0] <= 10_000_000 # 진입 수수료로 약간 작거나 같음
29+
ret = (nav.iloc[-1] / 10_000_000 - 1) * 100
30+
assert 45 <= ret <= 52
31+
32+
33+
def test_stats_computes_mdd_negative():
34+
import tools.buy_hold_robustness as bhr
35+
36+
dates = pd.date_range("2023-01-01", periods=100, freq="D")
37+
# 올랐다가 떨어지는 NAV → MDD 음수
38+
up = [10_000_000 * (1 + 0.5 * i / 49) for i in range(50)]
39+
down = [up[-1] * (1 - 0.3 * i / 49) for i in range(50)]
40+
nav = pd.Series(up + down, index=dates)
41+
total, sharpe, mdd = bhr._stats(nav)
42+
assert mdd < 0
43+
assert mdd <= -25 # 고점 대비 30% 하락 구간 포함
44+
45+
46+
def test_analyze_splits_periods_and_rolling(monkeypatch):
47+
import tools.buy_hold_robustness as bhr
48+
49+
# 2022~2025 합성: 2022 하락, 2023~2025 상승
50+
dates = pd.date_range("2022-01-01", "2025-12-31", freq="D")
51+
n = len(dates)
52+
# 2022 -20%, 이후 꾸준히 상승하는 단순 경로
53+
vals = []
54+
for i, d in enumerate(dates):
55+
if d.year == 2022:
56+
vals.append(100 - 20 * (d.dayofyear / 365))
57+
else:
58+
base = 80
59+
yrs_in = (d.year - 2023) + d.dayofyear / 365
60+
vals.append(base * (1 + 0.3 * yrs_in))
61+
s = pd.Series(vals, index=dates)
62+
panel = pd.DataFrame({sym: s.copy() for sym in bhr.SYMBOLS})
63+
64+
monkeypatch.setattr("core.data_collector.DataCollector", lambda: _fake_collector(panel))
65+
r = bhr.analyze()
66+
67+
assert r["symbols"] == len(bhr.SYMBOLS)
68+
# 2022 구간은 음수여야 함
69+
assert r["periods"]["2022 (약세장 스트레스)"][0] < 0
70+
# 롤링 1년 분포가 계산됨
71+
assert r["rolling_1y"]["count"] > 0
72+
assert "negative_pct" in r["rolling_1y"]
73+
74+
75+
def test_analyze_no_data_raises(monkeypatch):
76+
import tools.buy_hold_robustness as bhr
77+
empty = pd.DataFrame()
78+
monkeypatch.setattr("core.data_collector.DataCollector", lambda: _fake_collector(empty))
79+
with pytest.raises(SystemExit):
80+
bhr.analyze()

tools/buy_hold_robustness.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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

Comments
 (0)