연금저축 · 실측 재현 페이지

연금저축 계좌,
일주일에 한 번만 보는 법

연금저축 계좌에 매달 75만원13.9년. 넣은 돈은 1억 2,600만원입니다.
흔히 권하는 대로 주식형·채권형을 섞어 두고 해마다 한 번 맞춘 계좌는 3억 5,271만원, 일주일에 한 번 보고 통째로 옮긴 계좌는 5억 3,520만원이 됐습니다.
설정값·요일 다섯 가지 성적·재현 코드를 전부 아래에 열어 둡니다.

3억 5,271만원연금 표준 처방(6:4·연 1회)
5억 3,520만원일주일에 한 번 보는 규칙
6억 5,599만원그냥 나스닥100 보유 — 돈은 이쪽이 더 많습니다

이번에 드리는 설정값

그대로 옮겨 적으시면 됩니다. 담는 것은 두 가지뿐입니다.

13.9년 실측

2012-10-22 ~ 2026-09-16 · 분배금 보정(총수익) · 편도 0.015% · 매달 첫 거래일 75만원 적립 · 총납입 1억 2,600만원
낙폭은 계좌 잔고 기준입니다(실제로 앱에 뜨는 숫자). 첫 1년은 표본이 얇아 뺐습니다.

계좌최종 잔고IRR잔고 최대낙폭주식에 있던 비율연 매매
연금 표준 처방 — 주식형 6 : 채권형 4 · 해마다 한 번 맞추기3억 5,271만원13.70%-18.51%0.620.6회
이 규칙 — 일주일에 한 번 보고 통째로 옮기기 (월요일)5억 3,520만원18.92%-21.58%0.745.5회
반론용 — 같은 시간만큼 주식에 앉혀 두기만 한 계좌4억 2,555만원16.06%-21.82%0.740.5회
그냥 나스닥100 만 들고 있기6억 5,599만원21.44%-28.62%1.000.0회

요일은 운이 아닙니다

어느 요일에 보든 다섯 요일 전부 연금 표준 처방(3억 5,271만원)을 이겼습니다. 특정 요일이 좋다고 말하지 않습니다 — 아무 요일이나 하나 정해서 지키라는 것이 이 편의 처방입니다.

보는 요일최종 잔고IRR잔고 최대낙폭주식에 있던 비율연 매매
월요일5억 3,520만원18.92%-21.58%0.745.5회
화요일5억 4,064만원19.04%-17.92%0.726.5회
수요일4억 9,964만원18.06%-18.77%0.727.3회
목요일5억 2,501만원18.68%-16.06%0.736.9회
금요일5억 6,414만원19.57%-16.21%0.736.8회

"그냥 주식에 더 오래 앉아 있어서 아닌가"

이 반론은 돈으로 답해야 합니다. 이 규칙이 주식에 앉아 있던 비율(0.74)을 그대로 고정해 두고 한 번도 옮기지 않은 계좌를 나란히 세웠습니다.

그 계좌는 4억 2,555만원, 이 규칙은 5억 3,520만원입니다. 같은 만큼 넣고 가만히 있었으면 이 성적이 나오지 않습니다. 옮긴 것이 번 것입니다.

못 하는 것 — 숨기지 않습니다

재현 코드 — 이 파일 하나면 됩니다

numpy 만 있으면 돌아갑니다. 그날 시세를 얼려 두었으므로 python run_pension.py 하면 위 표의 숫자가 그대로 나옵니다.

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 입니다. 같은 폴더에 두고 돌리십시오. 지우면 야후에서 새로 받는데, 분배금 보정이 갱신돼 끝자리가 조금 달라질 수 있습니다.

투자 권유가 아닙니다. 과거 데이터로 돌린 결과이며 미래 수익을 보장하지 않습니다. 연금저축 계좌의 세금은 계산에 넣지 않았습니다. 투자 판단과 책임은 본인에게 있습니다.