연금저축 · 실측 재현 페이지
연금저축 계좌에 매달 75만원씩 13.9년. 넣은 돈은 1억 2,600만원입니다.
흔히 권하는 대로 주식형·채권형을 섞어 두고 해마다 한 번 맞춘 계좌는 3억 5,271만원,
일주일에 한 번 보고 통째로 옮긴 계좌는 5억 3,520만원이 됐습니다.
설정값·요일 다섯 가지 성적·재현 코드를 전부 아래에 열어 둡니다.
그대로 옮겨 적으시면 됩니다. 담는 것은 두 가지뿐입니다.
2012-10-22 ~ 2026-09-16 · 분배금 보정(총수익) · 편도 0.015% · 매달 첫 거래일 75만원 적립 · 총납입 1억 2,600만원
낙폭은 계좌 잔고 기준입니다(실제로 앱에 뜨는 숫자). 첫 1년은 표본이 얇아 뺐습니다.
| 계좌 | 최종 잔고 | IRR | 잔고 최대낙폭 | 주식에 있던 비율 | 연 매매 |
|---|---|---|---|---|---|
| 연금 표준 처방 — 주식형 6 : 채권형 4 · 해마다 한 번 맞추기 | 3억 5,271만원 | 13.70% | -18.51% | 0.62 | 0.6회 |
| 이 규칙 — 일주일에 한 번 보고 통째로 옮기기 (월요일) | 5억 3,520만원 | 18.92% | -21.58% | 0.74 | 5.5회 |
| 반론용 — 같은 시간만큼 주식에 앉혀 두기만 한 계좌 | 4억 2,555만원 | 16.06% | -21.82% | 0.74 | 0.5회 |
| 그냥 나스닥100 만 들고 있기 | 6억 5,599만원 | 21.44% | -28.62% | 1.00 | 0.0회 |
어느 요일에 보든 다섯 요일 전부 연금 표준 처방(3억 5,271만원)을 이겼습니다. 특정 요일이 좋다고 말하지 않습니다 — 아무 요일이나 하나 정해서 지키라는 것이 이 편의 처방입니다.
| 보는 요일 | 최종 잔고 | IRR | 잔고 최대낙폭 | 주식에 있던 비율 | 연 매매 |
|---|---|---|---|---|---|
| 월요일 | 5억 3,520만원 | 18.92% | -21.58% | 0.74 | 5.5회 |
| 화요일 | 5억 4,064만원 | 19.04% | -17.92% | 0.72 | 6.5회 |
| 수요일 | 4억 9,964만원 | 18.06% | -18.77% | 0.72 | 7.3회 |
| 목요일 | 5억 2,501만원 | 18.68% | -16.06% | 0.73 | 6.9회 |
| 금요일 | 5억 6,414만원 | 19.57% | -16.21% | 0.73 | 6.8회 |
이 반론은 돈으로 답해야 합니다. 이 규칙이 주식에 앉아 있던 비율(0.74)을 그대로 고정해 두고 한 번도 옮기지 않은 계좌를 나란히 세웠습니다.
그 계좌는 4억 2,555만원, 이 규칙은 5억 3,520만원입니다. 같은 만큼 넣고 가만히 있었으면 이 성적이 나오지 않습니다. 옮긴 것이 번 것입니다.
numpy 만 있으면 돌아갑니다. 그날 시세를 얼려 두었으므로
python run_pension.py 하면 위 표의 숫자가 그대로 나옵니다.
# -*- coding: utf-8 -*-
r"""연금저축 계좌 — 「일주일에 한 번만 보는 법」 편 재현 코드 (AlgoLab, 2026-09-17)
영상에서 말한 숫자를 **이 파일 하나로** 그대로 다시 냅니다.
python run_pension.py
필요한 것: numpy 만 있으면 됩니다(pandas 불필요).
데이터: 옆 폴더 `data_pension/` 에 그날 시세를 얼려 두었습니다. 그 폴더가 있으면 그것을 쓰고,
없으면 야후 파이낸스에서 새로 받습니다. **새로 받으면 분배금 보정이 갱신돼 끝자리가
조금 달라질 수 있습니다** — 영상과 똑같은 숫자를 보려면 얼린 시세를 그대로 두십시오.
규칙 (영상에서 공개한 설정 그대로)
· 담는 것은 두 가지 — 국내상장 나스닥100 ETF(133690) · 국고채10년 ETF(148070)
· 일주일에 한 번, 정한 요일 종가에만 본다
· 나스닥100 종가 > 50일 이동평균 → 나스닥100 100%, 아니면 국고채10년 100%
· 그 주에 위아래가 안 바뀌면 아무것도 하지 않는다
정직하게 잰 방법
· 분배금 보정(총수익) 종가 · 편도 0.015% (국내 ETF 는 증권거래세 면제)
· 신호는 점검일 종가로 판단하고 체결은 **다음 거래일** (룩어헤드 없음)
· 두 종목이 모두 거래된 날만 씁니다
· 매달 첫 거래일에 75만원씩 넣는 적립식(연금저축 세액공제 한도 연 900만원)
· 계좌 잔고 최대낙폭은 첫 1년을 뺍니다(표본이 얇아 숫자가 튑니다)
안 넣은 것: 호가 스프레드·괴리율·세금(과세이연·연금소득세·중도해지 기타소득세).
매수만 하고 하락에 거는 기능은 없습니다. 두 ETF 다 지금 상장돼 있는 종목입니다(생존편향).
투자 권유가 아닙니다.
"""
import csv
import datetime
import io
import os
import sys
import numpy as np
ASOF = "2026-09-16" # 영상을 만든 날의 직전 거래일. None 으로 바꾸면 오늘까지
RISKY, RN = "133690.KS", "나스닥100"
SAFE, SN = "148070.KS", "국고채10년"
SMA_N = 50 # 50일 이동평균
WARM = 250 # 워밍업(전 칸 공통)
COST = 0.00015 # 편도 0.015%
DEP = 750000.0 # 매달 납입
WD = ["월", "화", "수", "목", "금"]
HERE = os.path.dirname(os.path.abspath(__file__))
FROZEN = os.path.join(HERE, "data_pension")
# ── 시세 ────────────────────────────────────────────────────────────────
def load(sym):
p = os.path.join(FROZEN, sym.replace(".", "_") + "_1d.csv")
if not os.path.exists(p):
p = fetch(sym)
d, c = [], []
with io.open(p, encoding="utf-8") as f:
next(f)
for row in csv.reader(f):
if len(row) < 5:
continue
d.append(row[0]); c.append(float(row[4]))
return np.array(d), np.array(c)
def fetch(sym):
import json
import urllib.request
os.makedirs(FROZEN, exist_ok=True)
url = ("https://query1.finance.yahoo.com/v8/finance/chart/%s"
"?period1=0&period2=9999999999&interval=1d&events=div%%2Csplit" % sym)
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
j = json.load(urllib.request.urlopen(req, timeout=30))["chart"]["result"][0]
ts = j["timestamp"]
q = j["indicators"]["quote"][0]
adj = j["indicators"]["adjclose"][0]["adjclose"]
p = os.path.join(FROZEN, sym.replace(".", "_") + "_1d.csv")
with io.open(p, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["date", "open", "high", "low", "close", "volume"])
for i, t in enumerate(ts):
if adj[i] is None or q["close"][i] is None:
continue
k = adj[i] / q["close"][i] # 분배금 보정 배수
w.writerow([datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"),
"%.6f" % (q["open"][i] * k), "%.6f" % (q["high"][i] * k),
"%.6f" % (q["low"][i] * k), "%.6f" % adj[i], q["volume"][i] or 0])
print(" · %s 새로 받음 → %s" % (sym, p))
return p
def align(sa, sb):
da, ca = load(sa); db, cb = load(sb)
common = np.intersect1d(da, db) # 두 다리가 모두 거래된 날만
return list(common), ca[np.searchsorted(da, common)], cb[np.searchsorted(db, common)]
# ── 규칙 ────────────────────────────────────────────────────────────────
def sma(x, n):
k = np.convolve(x, np.ones(n) / n, "full")[:len(x)]
k[:n - 1] = np.nan
return k
def mask_weekday(dates, wd):
"""그 요일의 거래일. 휴장이면 그 주는 가장 가까운 이전 거래일로 당긴다."""
m = np.zeros(len(dates), bool)
week, best = None, -1
for i, s in enumerate(dates):
iso = datetime.date.fromisoformat(s).isocalendar()
w = (iso[0], iso[1])
if w != week:
if best >= 0:
m[best] = True
week, best = w, -1
if iso[2] - 1 <= wd:
best = i
if best >= 0:
m[best] = True
return m
def check_mask(dates, freq):
"""그 주기의 마지막 거래일에만 True."""
m = np.zeros(len(dates), bool)
keys = []
for s in dates:
y, mo, da = int(s[:4]), int(s[5:7]), int(s[8:10])
keys.append(datetime.date(y, mo, da).isocalendar()[:2] if freq == "주1회"
else (y, mo) if freq == "월1회" else (y,))
for i in range(len(keys)):
if i == len(keys) - 1 or keys[i] != keys[i + 1]:
m[i] = True
return m
def desired(cr, n, dates, wd):
"""점검일(그 요일)에만 갱신되는 목표 위험비중 — 0 또는 1."""
smav = sma(cr, n)
raw = (cr > smav).astype(float); raw[np.isnan(smav)] = 0.0
m = mask_weekday(dates, wd)
des = np.zeros(len(cr)); x = 0.0
for t in range(len(cr)):
if m[t]:
x = raw[t]
des[t] = x
return des
def fixed_path(ra, rb, w, dates, freq):
"""고정 비중 계좌 — 사이에 표류하고 점검일에 원래 비중으로 되돌린다."""
n = len(ra); cur = np.zeros(n); x = w
m = check_mask(dates, freq)
for t in range(n):
cur[t] = x
x = x * (1 + ra[t]) / (x * (1 + ra[t]) + (1 - x) * (1 + rb[t]))
if m[t]:
x = w
return cur
# ── 계산 ────────────────────────────────────────────────────────────────
def path_stats(pos, ra, rb, dates):
turn = np.abs(np.diff(np.r_[pos[0], pos])) * 2 # 팔고 사고 두 다리
g = (1 + pos * ra + (1 - pos) * rb) * (1 - COST * turn)
eq = np.cumprod(g)
yrs = (datetime.date.fromisoformat(dates[-1])
- datetime.date.fromisoformat(dates[0])).days / 365.25
return g, dict(CAGR=float(eq[-1] ** (1 / yrs) - 1),
MDD=float(np.min(eq / np.maximum.accumulate(eq)) - 1),
노출=float(pos.mean()), 회전=float(turn.sum() / 2 / yrs), 연수=yrs)
def 실제체결(w, dates, ra, rb, freq):
"""되맞춤 날 **장 마감 뒤**의 표류 비중과 목표 w 의 차이 = 그날 실제로 옮기는 몫.
⚠ 위 `회전`(Σ|Δpos|/년) 을 '갈아탄 횟수' 로 읽으면 안 된다 — 고정비중 계좌의 비중은
날마다 저절로 표류하고 되맞춤 날에만 목표로 되돌아간다. 그 표류가 회전에 다 들어간다.
⚠ `|pos[t+1] - pos[t]|` 로 세면 **되맞춤 당일의 표류 하루치가 빠진다**(체결을 4%
적게 잡고 배수를 부풀린다). 점검 마스크를 `m[:-1]` 로 자르면 마지막 점검일이 버려진다.
"""
m = check_mask(dates, freq)
yrs = (datetime.date.fromisoformat(dates[-1])
- datetime.date.fromisoformat(dates[0])).days / 365.25
x, 합, cnt = w, 0.0, 0
for t in range(len(dates)):
after = x * (1 + ra[t]) / (x * (1 + ra[t]) + (1 - x) * (1 + rb[t]))
if m[t]:
합 += abs(w - after); cnt += 1; x = w
else:
x = after
return float(합 / yrs), cnt, float(cnt / yrs)
def dep_mask(dates):
m = np.zeros(len(dates), bool); prev = None
for i, s in enumerate(dates):
if s[:7] != prev:
m[i] = True; prev = s[:7]
return m
def dca(g, dmask):
"""적립 잔고: b_t = (b_{t-1} + 납입) * g_t"""
d = np.where(dmask, DEP * (1 - COST), 0.0)
G = np.cumprod(g)
return G * np.cumsum(d / (G / g)), np.cumsum(np.where(dmask, DEP, 0.0))
def irr_annual(dates, dmask, final):
end = datetime.date.fromisoformat(dates[-1])
yrs = np.array([(end - datetime.date.fromisoformat(dates[i])).days / 365.25
for i in np.nonzero(dmask)[0]])
lo, hi = -0.95, 3.0
for _ in range(120):
mid = (lo + hi) / 2
if float(np.sum(DEP * (1 + mid) ** yrs)) < final:
lo = mid
else:
hi = mid
return (lo + hi) / 2
def bal_mdd(B, dates):
i1 = int(np.searchsorted(np.asarray(dates),
(datetime.date.fromisoformat(dates[0])
+ datetime.timedelta(days=365)).isoformat()))
bm = B[i1:]
return float(np.min(bm / np.maximum.accumulate(np.maximum(bm, 1.0))) - 1)
def man(x):
return "%s만원" % format(int(round(x / 1e4)), ",")
def main():
d, cr, cs = align(RISKY, SAFE)
if ASOF:
n = int(np.searchsorted(np.asarray(d), ASOF, side="right"))
d, cr, cs = d[:n], cr[:n], cs[:n]
ra = np.zeros(len(cr)); ra[1:] = cr[1:] / cr[:-1] - 1
rb = np.zeros(len(cs)); rb[1:] = cs[1:] / cs[:-1] - 1
dd, A, B_ = d[WARM:], ra[WARM:], rb[WARM:]
dm = dep_mask(dd)
def run(pos):
g, st = path_stats(pos, A, B_, dd)
bal, cum = dca(g, dm)
st.update(잔고=float(bal[-1]), 총납입=float(cum[-1]),
IRR=irr_annual(dd, dm, float(bal[-1])), 잔고MDD=bal_mdd(bal, dd))
return st
print("== %s ~ %s (%.1f년) · %s + %s · %d일선 · 주 1회 · 매달 %s 적립 =="
% (dd[0], dd[-1],
(datetime.date.fromisoformat(dd[-1])
- datetime.date.fromisoformat(dd[0])).days / 365.25,
RN, SN, SMA_N, man(DEP)))
print("%-26s %12s %8s %10s %9s %8s"
% ("", "최종 잔고", "IRR", "잔고낙폭", "노출", "회전"))
def show(lab, st):
print("%-26s %12s %7.2f%% %9.2f%% %9.2f %7.1f"
% (lab, man(st["잔고"]), st["IRR"] * 100, st["잔고MDD"] * 100,
st["노출"], st["회전"]))
bots = {}
for wd, wn in enumerate(WD):
des = desired(cr, SMA_N, d, wd)
pos = np.r_[des[WARM - 1], des[WARM:len(d) - 1]] # 전봉 결정 → 당봉 체결
bots[wn] = run(pos)
show("이 규칙 — %s요일" % wn, bots[wn])
st64 = run(fixed_path(A, B_, 0.6, dd, "연1회"))
show("연금 표준 처방 6:4 연1회", st64)
stx = run(fixed_path(A, B_, bots["월"]["노출"], dd, "월1회"))
show("같은 노출 고정 계좌(반론)", stx)
sth = run(np.ones(len(dd)))
show("그냥 %s 보유" % RN, sth)
print("%-26s %12s" % ("총납입", man(st64["총납입"])))
잔고 = sorted(bots[w]["잔고"] for w in WD)
중앙 = 잔고[2]
print("\n영상에서 말한 숫자")
print(" 이 규칙(요일 가운데 값) %s · 요일 전부 %s ~ %s"
% (man(중앙), man(잔고[0]), man(잔고[-1])))
print(" 연금 표준 처방 %s" % man(st64["잔고"]))
print(" 같은 노출 고정 계좌 %s" % man(stx["잔고"]))
print(" 그냥 보유 %s ← 돈은 이쪽이 더 많습니다(영상에서 숨기지 않았습니다)"
% man(sth["잔고"]))
print(" 넣은 돈 %s" % man(st64["총납입"]))
# ── 실제로 옮기는 금액 ──────────────────────────────────────────────────
# ⚠ 위 표의 '회전' 은 표류를 포함한다 — 갈아탄 횟수로 읽으면 안 된다.
pos봇 = np.r_[desired(cr, SMA_N, d, 0)[WARM - 1],
desired(cr, SMA_N, d, 0)[WARM:len(d) - 1]]
t봇 = np.abs(np.diff(np.r_[pos봇[0], pos봇]))
yrs봇 = (datetime.date.fromisoformat(dd[-1])
- datetime.date.fromisoformat(dd[0])).days / 365.25
a봇, c봇 = float(t봇.sum() / yrs봇), int((t봇 > 1e-9).sum())
r봇 = c봇 / yrs봇
a64, c64, r64 = 실제체결(0.6, dd, A, B_, "연1회")
print("\n실제로 옮기는 금액 (되맞춤 날의 점프만 센 것)")
print(" 이 규칙(월) 해마다 계좌의 %.2f배 · 갈아타기 %d회 (연 %.2f회)" % (a봇, c봇, r봇))
print(" 연금 표준 처방 해마다 계좌의 %.2f배 · 되맞춤 %d회 (연 %.2f회)" % (a64, c64, r64))
print(" → 이 규칙이 %.0f배 많이 옮깁니다 (갈아타는 몫만 · 적립 매수는 뺀 값)"
% (a봇 / a64))
print(" ※ 위 표의 '회전' 은 고정비중 계좌의 날마다의 표류까지 포함한 값이라 갈아탄 횟수가")
print(" 아닙니다. 이 계산은 비용 모형에서도 그 표류에 수수료를 물립니다 — 실제로는 내지")
print(" 않을 수수료라, 고정 계좌 쪽이 실제보다 조금 불리하게 잡혀 있습니다.")
print(" 즉 이 규칙에 유리한 쪽으로 기운 오차입니다. 걷어내고 다시 재면")
print(" 연금 표준 처방 3억 5,322만원(+51만원 · +0.145%), 같은 노출 고정 계좌")
print(" 4억 2,605만원(+49만원 · +0.116%) 입니다. 결론은 바뀌지 않습니다.")
ok = (중앙 > st64["잔고"] and 중앙 > stx["잔고"] and sth["잔고"] > 중앙
and all(bots[w]["잔고"] > st64["잔고"] for w in WD)
and bots["월"]["잔고MDD"] > sth["잔고MDD"])
print("\n%s" % ("✓ 영상의 주장 다섯 개가 이 계산에서 그대로 나옵니다"
if ok else "✗ 주장이 재현되지 않습니다 — 시세가 갱신된 것인지 확인하십시오"))
return 0 if ok else 3
if __name__ == "__main__":
sys.exit(main())
얼린 시세는 /downloads/data_pension/133690_KS_1d.csv ·
/downloads/data_pension/148070_KS_1d.csv 입니다. 같은 폴더에 두고 돌리십시오.
지우면 야후에서 새로 받는데, 분배금 보정이 갱신돼 끝자리가 조금 달라질 수 있습니다.