코스피 시가총액 상위 24종목을 같은 비중으로 담고, 2015년 1월부터 2026년 8월까지
2,855거래일을 계산했습니다. 위험 요인을 기준으로 센 유효 베팅 수(ENB)는 1.06이었습니다.
암호화폐 19종목은 1.01, 반면 주식·채권·금·원유·단기채권 5자산은 2.31이었습니다.
이 페이지에 계산 코드를 통째로 펼쳐 뒀습니다. 내려받지 않으셔도 그대로 읽고 검증하실 수 있습니다.
아래 두 코드로 그대로 재현됩니다.
| 항목 | 코스피 24종목 | 암호화폐 19종목 | 멀티에셋 5자산 |
|---|---|---|---|
| 평균 상관계수 | 0.28 | 0.59 | 0.07 |
| 개별 연변동성 평균 | 40.6% | 80.2% | 17.1% |
| 등가중 포트 연변동성 | 22.4% | 62.4% | 10.6% |
| 이론 하한 (σ√ρ) | 21.5% | 61.6% | 4.4% |
| ENB (유효 베팅 수) | 1.06 | 1.01 | 2.31 |
| 최대 요인의 위험 비중 | 99.3% | 99.9% | 65.0% |
코스피 2015-01-05 ~ 2026-08-21 (2,855거래일) · 코인 2024-06-21 ~ 2026-08-24 (795거래일, 업비트 KRW 마켓)
99.3%는 가격이 99.3% 똑같다는 뜻이 아니라, 포트폴리오 위험에서 최대 요인이 차지하는 비중입니다.
보유 종목 코드만 넣으면 됩니다. 영상에서 쓴 것과 같은 계산입니다.
필요한 것 — 파이썬 3와 패키지 3개.
pip install finance-datareader pandas numpy
아래 코드를 복사하거나 enb_calc.py를 내려받습니다.
보유 종목 코드를 그대로 붙여 실행합니다.
python enb_calc.py 005930 000660 035420 051910 005380
비중이 다르면 코드:비중 으로.
python enb_calc.py 005930:40 000660:30 035420:30
# -*- coding: utf-8 -*-
"""내 포트폴리오의 ENB(유효 베팅 수) 계산기
알고랩 「24종목을 샀는데 실질 베팅 수는 1.06이었습니다」 부록 · https://algolab.co.kr/enb
영상에서 쓴 것과 **같은 계산**입니다. 종목 코드만 넣으면 됩니다.
pip install finance-datareader pandas numpy # --csv 만 쓸 거면 pandas numpy 로 충분
# 종목 코드 (등가중)
python enb_calc.py 005930 000660 035420 051910 005380
# 비중을 직접 주고 싶으면 코드:비중
python enb_calc.py 005930:40 000660:30 035420:30
# 기간 바꾸기
python enb_calc.py 005930 000660 --start 2020-01-01
# 직접 받은 종가 CSV로 (첫 열 날짜, 나머지 열이 종목)
python enb_calc.py --csv my_prices.csv
무엇을 보는가
ENC = 비중만 보고 센 '유효 종목 수'. 등가중 N종목이면 N.
ENB = 위험 요인 기준 '유효 베팅 수'. **ENC보다 한참 작으면 종목 수만 늘린 것.**
DR = 분산화 비율. 개별 변동성 가중합 / 포트 변동성.
주의 — 영상에서도 말한 한계입니다.
· ENB 1.06 은 "한 종목만 가지고 있다"는 뜻이 아닙니다.
균등하게 분산된 N개의 독립적인 베팅과 거리가 멀다는 뜻입니다.
· 기간·종목 선정·요인 추출 방식에 따라 숫자는 달라집니다. 절대 기준이 아닙니다.
· 과거 데이터로 잰 값이며, 미래의 분산 효과를 보장하지 않습니다.
· 투자 권유가 아닙니다. 투자 판단과 결과의 책임은 본인에게 있습니다.
"""
from __future__ import annotations
import sys
import argparse
import numpy as np
import pandas as pd
TRADING_DAYS = 252 # 연율화. 24시간 거래(코인)면 --ann 365
def load_prices(codes, start, end):
"""FinanceDataReader 로 종가를 받아 하나의 표로 합친다."""
try:
import FinanceDataReader as fdr
except ImportError:
sys.exit("finance-datareader 가 필요합니다: pip install finance-datareader\n"
"(또는 --csv 로 직접 받은 종가 파일을 주세요)")
cols = {}
for c in codes:
df = fdr.DataReader(c, start, end)
if df.empty:
sys.exit("데이터를 못 받았습니다: %s" % c)
cols[c] = df["Close"]
return pd.DataFrame(cols).sort_index()
def log_returns(px, min_ratio=0.9):
"""로그수익률. 이력이 짧아 결측이 많은 종목은 뺀다(짧은 종목이 표를 다 깎아먹는다)."""
r = np.log(px / px.shift(1))
keep = r.columns[r.notna().sum() >= min_ratio * len(r)]
dropped = [c for c in r.columns if c not in keep]
if dropped:
print(" ! 이력이 부족해 제외: %s" % ", ".join(map(str, dropped)))
return r[keep].dropna()
def enb(cov, w):
"""Meucci ENB — 공분산 주성분으로 위험을 회전시킨 뒤 비중 분포의 엔트로피 지수.
각 주성분이 포트폴리오 분산에서 차지하는 몫 p 에 대해 ENB = exp(-Σ p ln p).
한 요인이 전부를 설명하면 1, 요인들이 고르게 나눠 가지면 커진다.
"""
vals, vecs = np.linalg.eigh(cov)
o = np.argsort(vals)[::-1]
vals, vecs = vals[o], vecs[:, o]
contrib = (vecs.T @ w) ** 2 * vals
p = np.clip(contrib, 0, None)
p = p / p.sum()
nz = p[p > 1e-15]
return float(np.exp(-(nz * np.log(nz)).sum())), p
def main():
ap = argparse.ArgumentParser(
description="내 포트폴리오의 유효 베팅 수(ENB)를 계산합니다.")
ap.add_argument("holdings", nargs="*",
help="종목코드 또는 코드:비중 (예: 005930:40 000660:30)")
ap.add_argument("--csv", default=None, help="종가 CSV(첫 열 날짜, 나머지 열이 종목)")
ap.add_argument("--start", default="2015-01-01")
ap.add_argument("--end", default=None)
ap.add_argument("--ann", type=int, default=TRADING_DAYS,
help="연율화 일수. 주식 252(기본) · 코인 365")
a = ap.parse_args()
if a.csv:
px = pd.read_csv(a.csv, index_col=0, encoding="utf-8-sig")
px.index = pd.to_datetime(px.index)
px = px.sort_index().loc[a.start:a.end]
weights = None
else:
if not a.holdings:
ap.error("종목 코드를 주거나 --csv 를 쓰세요.")
codes, ws = [], []
for h in a.holdings:
code, _, wt = h.partition(":")
codes.append(code)
ws.append(float(wt) if wt else None)
px = load_prices(codes, a.start, a.end)
weights = None if any(w is None for w in ws) else ws
r = log_returns(px)
n = r.shape[1]
if n < 2:
sys.exit("종목이 2개 이상이어야 계산할 수 있습니다.")
if weights is not None and len(weights) == len(px.columns):
w = np.array([weights[list(px.columns).index(c)] for c in r.columns], float)
w = w / w.sum()
else:
w = np.ones(n) / n # 등가중
S = np.cov(r.values.T) * a.ann # 연율화 공분산
C = np.corrcoef(r.values.T)
sig = np.sqrt(np.diag(S))
port = float(np.sqrt(w @ S @ w))
rho = float(C[~np.eye(n, dtype=bool)].mean())
enc = float(1.0 / (w ** 2).sum()) # 비중만 본 유효 종목 수
dr = float((w @ sig) / port) # 분산화 비율
e, p = enb(S, w)
print()
print(" 기간 {} ~ {} ({}거래일)".format(
r.index[0].date(), r.index[-1].date(), len(r)))
print(" 종목 {}개 — {}".format(n, ", ".join(map(str, r.columns))))
print(" 비중 {}".format("등가중" if weights is None else
" · ".join("%.0f%%" % (x * 100) for x in w)))
print(" " + "-" * 58)
print(" 평균 상관계수 {:6.2f}".format(rho))
print(" 개별 연변동성 평균 {:6.1f}%".format(sig.mean() * 100))
print(" 포트 연변동성 {:6.1f}%".format(port * 100))
print(" " + "-" * 58)
print(" ENC (비중만) {:6.2f}".format(enc))
print(" DR (분산화 비율) {:6.2f}".format(dr))
print(" ENB (유효 베팅 수) {:6.2f} <-- 이 숫자".format(e))
print(" 최대 요인의 위험 비중 {:6.1f}%".format(p[0] * 100))
print()
print(" 종목은 {}개인데 위험 기준 유효 베팅 수는 {:.2f}입니다.".format(n, e))
print(" ENB 가 종목 수보다 한참 작다면, 늘린 것은 종목 수이지 위험의 원천이 아닙니다.")
print(" ※ 절대 기준이 아닙니다. 기간·종목·계산 방식에 따라 달라집니다.")
print()
if __name__ == "__main__":
main()
1.06 / 1.01 / 2.31 이 어떻게 나왔는지 직접 확인하는 코드입니다. 연도별·윈저화·회전방식 3중 검증까지 그대로 돌아갑니다.
데이터를 내려받아 압축을 풉니다. enb_data.zip (약 294KB)
data/ 폴더가 스크립트와 같은 자리에 오게 둡니다.
python enb_analyze.py 실행 → results/enb_results.json 에 원본이 남습니다. (pandas·numpy 외 의존성 없음)
# -*- coding: utf-8 -*-
"""
ENB(유효 베팅 수) 실측 + 견고성 검증
pip install pandas numpy # 그 외 의존성 없음. data/ 폴더가 이 파일 옆에 있어야 한다.
python enb_analyze.py # → results/enb_results.json
· 유니버스 3종: KOSPI 상위30 / 업비트 상위20 / 멀티에셋 8
· 연도별, 윈저화, 회전방식(공분산PCA vs 상관PCA) 3중 검증
"""
import os, sys, json
import numpy as np, pandas as pd
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
HERE = os.path.dirname(os.path.abspath(__file__))
DATA, OUT = os.path.join(HERE, "data"), os.path.join(HERE, "results")
os.makedirs(OUT, exist_ok=True)
# ───────────────────────────────────────────────────────── 유틸
def load(fn, names_fn=None):
px = pd.read_csv(os.path.join(DATA, fn), index_col=0, encoding="utf-8-sig")
px.index = pd.to_datetime(px.index)
if names_fn and os.path.exists(os.path.join(DATA, names_fn)):
px = px.rename(columns=json.load(open(os.path.join(DATA, names_fn), encoding="utf-8")))
return px.sort_index()
def logret(px, min_obs_ratio=0.9):
r = np.log(px / px.shift(1))
keep = r.columns[r.notna().sum() >= min_obs_ratio * len(r)]
return r[keep].dropna()
def winsorize(r, lo=0.01, hi=0.99):
return r.clip(r.quantile(lo), r.quantile(hi), axis=1)
def _entropy_enb(c):
c = np.clip(c, 0, None)
p = c / c.sum()
nz = p[p > 1e-15]
return float(np.exp(-(nz * np.log(nz)).sum())), p
def enb_cov(S, w):
"""Meucci ENB — 공분산 주성분 회전"""
vals, vecs = np.linalg.eigh(S)
o = np.argsort(vals)[::-1]
vals, vecs = vals[o], vecs[:, o]
wt = vecs.T @ w
return _entropy_enb((wt ** 2) * vals)
def enb_corr(C, w, sig):
"""상관 주성분 회전 (변동성 차이를 요인 정의에서 제거)"""
v = w * sig # 위험 스케일 가중
vals, vecs = np.linalg.eigh(C)
o = np.argsort(vals)[::-1]
vals, vecs = vals[o], vecs[:, o]
vt = vecs.T @ v
return _entropy_enb((vt ** 2) * vals)
def metrics(r, ann):
N, T = r.shape[1], r.shape[0]
w = np.ones(N) / N
S = np.cov(r.values.T) * ann
C = np.corrcoef(r.values.T)
sig = np.sqrt(np.diag(S))
sp = float(np.sqrt(w @ S @ w))
off = C[~np.eye(N, dtype=bool)]
e_cov, p_cov = enb_cov(S, w)
e_cor, _ = enb_corr(C, w, sig)
cvals = np.sort(np.linalg.eigvalsh(C))[::-1]
q = N / T
lam_p = (1 + np.sqrt(q)) ** 2
pr = r.values @ w
m = pr <= np.quantile(pr, 0.10)
rho_down = float(np.corrcoef(r.values[m].T)[~np.eye(N, dtype=bool)].mean()) if m.sum() > N else np.nan
mrc = S @ w
rc = w * mrc / sp
return dict(N=N, T=T, rho_bar=float(off.mean()), rho_down=rho_down,
sigma_mean=float(sig.mean()), sigma_port=sp,
ENC=float(1 / (w ** 2).sum()), DR=float((w @ sig) / sp),
ENB=e_cov, ENB_corr=e_cor,
pc1_share=float(p_cov[0]), pc_max_share=float(p_cov.max()),
mp_lambda_plus=float(lam_p),
n_eig_above_mp=int((cvals > lam_p).sum()),
top_rc=float((rc / rc.sum()).max()),
eigvals=[float(x) for x in cvals])
SPECS = [("KOSPI 상위 30종목", "kospi30_px.csv", "kospi30_names.json", 252),
("암호화폐 상위 20 (업비트)", "upbit20_px.csv", None, 365),
("멀티에셋 8자산 (ETF)", "multi_px.csv", "multi_names.json", 252)]
ALL = {}
for name, fn, nfn, ann in SPECS:
px = load(fn, nfn)
r_all = logret(px)
full = metrics(r_all, ann)
full_w = metrics(winsorize(r_all), ann)
# 연도별
by_year = {}
for y, g in r_all.groupby(r_all.index.year):
gg = g.dropna(axis=1, how="any")
if len(gg) < 100 or gg.shape[1] < 4:
continue
by_year[int(y)] = metrics(gg, ann)
ALL[name] = dict(period="%s ~ %s" % (r_all.index.min().date(), r_all.index.max().date()),
full=full, winsorized=full_w, by_year=by_year,
assets=list(r_all.columns))
print("\n" + "=" * 82)
print(" %s N=%d T=%d (%s)" % (name, full["N"], full["T"], ALL[name]["period"]))
print("=" * 82)
print(" 평균 쌍상관 ρ̄ %6.3f (하락 상위10%% 구간 %6.3f)" % (full["rho_bar"], full["rho_down"]))
print(" 개별 연변동성 평균 %6.1f%% 등가중 포트 %5.1f%%" % (full["sigma_mean"]*100, full["sigma_port"]*100))
print(" ENC (비중만) %6.1f" % full["ENC"])
print(" DR (분산화 비율) %6.2f" % full["DR"])
print(" " + "-" * 78)
print(" ENB 공분산PCA %6.2f | 윈저화 후 %5.2f | 상관PCA %5.2f"
% (full["ENB"], full_w["ENB"], full["ENB_corr"]))
print(" 최대 PC의 위험비중 %5.1f%%" % (full["pc_max_share"]*100))
print(" MP상한 초과 고유값 %d개 / %d개" % (full["n_eig_above_mp"], full["N"]))
if by_year:
ys = sorted(by_year)
print(" " + "-" * 78)
print(" 연도별 ENB(공분산PCA):")
print(" " + " ".join("%4d" % y for y in ys))
print(" " + " ".join("%4.2f" % by_year[y]["ENB"] for y in ys))
print(" 연도별 ρ̄:")
print(" " + " ".join("%4.2f" % by_year[y]["rho_bar"] for y in ys))
e = [by_year[y]["ENB"] for y in ys]
print(" → 연도별 ENB 범위 %.2f ~ %.2f (중앙값 %.2f)" % (min(e), max(e), float(np.median(e))))
json.dump(ALL, open(os.path.join(OUT, "enb_results.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=1)
print("\n\n" + "#" * 82)
print(" 최종 요약 — 등가중 포트폴리오의 유효 베팅 수")
print("#" * 82)
print(" %-26s %4s %6s %7s %7s %8s %8s" % ("유니버스", "N", "ρ̄", "ENC", "ENB", "윈저화", "연도범위"))
for k, v in ALL.items():
ys = sorted(v["by_year"])
e = [v["by_year"][y]["ENB"] for y in ys]
rng = "%.1f~%.1f" % (min(e), max(e)) if e else "-"
print(" %-26s %4d %6.2f %7.1f %7.2f %8.2f %10s"
% (k[:26], v["full"]["N"], v["full"]["rho_bar"], v["full"]["ENC"],
v["full"]["ENB"], v["winsorized"]["ENB"], rng))
print("\n→ results/enb_results.json")
enb_calc.py --csv data/kospi30_px.csv 로도 같은 1.06이 나옵니다.
숫자만 가져가지 마시고 이것도 같이 보셨으면 합니다.
"그럼 위험을 최소화하려면 종목 비중을 어떻게 정해야 할까?"
마코위츠 포트폴리오 최적화가 실전에서 왜 생각보다 잘 작동하지 않는지,
그리고 왜 단순한 1/N이 더 강할 때가 있는지 직접 데이터로 검증합니다.
※ 교육·검증 목적 자료 — 이 페이지의 수치와 코드는 널리 공개된 포트폴리오 이론(Meucci의
Effective Number of Bets)을 과거 데이터로 검증한 교육·검증 목적의 예시 자료입니다.
알고랩(퀀트웍스)은 투자자문업·투자일임업을 영위하지 않으며, 특정 종목·비중·매매 시점에 관한
개별 자문이나 추천을 제공하지 않습니다. 언급된 종목은 계산에 쓴 데이터일 뿐 매수·매도 권유가 아닙니다.
※ 과거 데이터로 측정한 값이며 미래의 분산 효과를 보장하지 않습니다.
모든 투자 판단과 그에 따른 책임은 이용자 본인에게 있습니다.