Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

노트북은 예측 → 계산 → 대조 세 부분으로 고정한다. 새 개념은 도입하지 않는다. 본문에서 이미 유도한 것을 수치로 확인할 뿐이다.

1. 예측 (코드를 쓰기 전에)

코드를 쓰기 전에 아래 셋에 답한다. 계산하지 않는다.

1. pp-불변. S0=100S_0=100, u=1.2u=1.2, d=0.8d=0.8, R=1.05R=1.05, K=100K=100에서 p{0.3,0.5,0.7,0.9}p\in\{0.3,0.5,0.7,0.9\} 네 경우의 콜 가격 R1EQ[X]R^{-1}\mathbb{E}^{Q}[X]는 네 값이 모두 같다(11.90). 같은 ppR1E[X]R^{-1}\mathbb{E}[X]를 계산하면 5.71·9.52·13.33·17.14로 달라진다. 예측: “네 값 동일 / 실제 확률 할인은 pp에 선형으로 증가”. 원인란에 적을 것: p<qp<q이면 프리미엄이 음수인데도 가격은 같다.

예측: ____

2. NN단계 → 연속 극한. σ=0.2\sigma=0.2, r=0.03r=0.03, Nh=1Nh=1, K=S0=100K=S_0=100의 나무 가격이 N=1,10,100,1000N=1,10,100,1000에서 11.30, 9.22, 9.39, 9.41로 수렴하고, 참조값 9.41과의 오차 1.88, -0.20, -0.02, -0.0021/N1/N 차수(log-log 기울기 -1)로 준다. 부호는 짝수 NN에서 음, 홀수 NN에서 양으로 진동하며 Nerr1.9N\cdot|\text{err}|\approx1.9로 일정하다(N=50N=50: -0.040, N=51N=51: +0.037). 예측: “오차 크기는 NN에 반비례, 부호는 NN의 홀짝으로 진동”. 원인란에 적을 것: 짝수 NN에서만 만기 마디 하나가 정확히 K=S0K=S_0 위에 놓인다.

예측: ____

3. 드리프트 이동. N=50N=50, μ=0.08\mu=0.08: PP-나무와 QQ-나무에서 ln(SN/S0)\ln(S_N/S_0)의 평균은 0.060과 0.010(차이 (μr)Nh=0.05(\mu-r)Nh=0.05), 분산은 둘 다 0.040(10-3 이내로 같음). 경로별 밀도 Z=(q/p)k((1q)/(1p))NkZ=\prod(q/p)^{k}((1-q)/(1-p))^{N-k}PP-평균은 1.000. 예측: “평균만 0.05 이동, 분산 불변, E[Z]=1\mathbb{E}[Z]=1”.

예측: ____

2. 계산

패키지가 돌려주는 옵션 가격은 답이 아니라 참조값이다. 페이오프·복제·역방향 반복을 직접 쌓는다.

# 도구를 올린다 — 값은 아래 셀들에서 직접 쌓는다.
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import font_manager
from matplotlib.ticker import FuncFormatter, NullFormatter
from scipy.special import comb, erf

# 한국어 글꼴을 고른다.
CANDIDATES = ['Apple SD Gothic Neo', 'AppleGothic', 'NanumGothic',
              'Noto Sans CJK KR', 'Malgun Gothic']
installed = {f.name for f in font_manager.fontManager.ttflist}
for name in CANDIDATES:
    if name in installed:
        plt.rcParams['font.family'] = name
        break
plt.rcParams['axes.unicode_minus'] = False
print('글꼴:', plt.rcParams['font.family'])
글꼴: ['Apple SD Gothic Neo']
# 한 기간 나무의 파라미터와 콜의 페이오프를 상태 2개 배열로 쌓는다.
S0, u, d, R, K = 100.0, 1.2, 0.8, 1.05, 100.0
S1 = np.array([d * S0, u * S0])            # 순서는 [T, H]
X = np.maximum(S1 - K, 0.0)
XT, XH = float(X[0]), float(X[1])

# (eq-w15-8) 복제 포트폴리오를 연립에서 직접 푼다.
Delta = (XH - XT) / ((u - d) * S0)
Bond = (u * XT - d * XH) / ((u - d) * R)
V0 = Delta * S0 + Bond

# (eq-w15-1)의 q로 (eq-w15-9)의 할인 기댓값을 따로 쌓아 맞춰 본다.
q = (R - d) / (u - d)
price_Q = (q * XH + (1 - q) * XT) / R

print(f'q = {q:.4f},  1-q = {1 - q:.4f}')
print(f'X_H = {XH:.2f},  X_T = {XT:.2f}')
print(f'Delta = {Delta:.4f},  B = {Bond:.4f}')
print(f'복제비용 V0 = {V0:.4f}   위험중립 할인 = {price_Q:.4f}   차이 = {V0 - price_Q:.2e}')
q = 0.6250,  1-q = 0.3750
X_H = 20.00,  X_T = 0.00
Delta = 0.5000,  B = -38.0952
복제비용 V0 = 11.9048   위험중립 할인 = 11.9048   차이 = 7.11e-15
# 예측 1 — p마다 m을 새로 풀어 할인인자 경로로 가격을 다시 쌓는다((eq-w15-13)).
ps = [0.3, 0.5, 0.7, 0.9]
RS = np.array([d, u])                      # 주식 총수익률 R_S = S_1/S_0
rows = []
for p in ps:
    mH, mT = q / (p * R), (1 - q) / ((1 - p) * R)          # (eq-w15-13)
    price_m = p * mH * XH + (1 - p) * mT * XT              # E[mX] — p를 넣은 채로 쌓는다
    m, w = np.array([mT, mH]), np.array([1 - p, p])
    cov = float(w @ (m * RS) - (w @ m) * (w @ RS))
    rows.append([p, mH, mT, q / p, (1 - q) / (1 - p), p * (q / p), price_m,
                 (p * XH + (1 - p) * XT) / R, float(w @ RS) - R, -R * cov])

head = ['p', 'm_H', 'm_T', 'Z(H)', 'Z(T)', 'p·Z(H)', 'E[mX]', 'P할인', 'E[R_S]-R', '-R·Cov']
print(''.join(f'{h:>10}' for h in head))
for row in rows:
    print(''.join(f'{v:10.4f}' for v in row))

prices_m = [row[6] for row in rows]
prices_P = [row[7] for row in rows]
QH = [row[5] for row in rows]
print(f'\nE[mX] 네 값 = {[round(v, 4) for v in prices_m]}  '
      f'(표준편차 {np.std(prices_m):.2e}, max|E[mX]-V0| = '
      f'{max(abs(v - V0) for v in prices_m):.2e})')
print(f'Q(H)=p·Z(H) 네 값 = {[round(v, 4) for v in QH]}  '
      f'(q = {q:.4f}, max 차 = {max(abs(v - q) for v in QH):.2e})')
print(f'P할인 네 값 = {[round(v, 2) for v in prices_P]}  (이웃 간 차 일정: '
      f'{np.round(np.diff(prices_P), 4).tolist()})')
         p       m_H       m_T      Z(H)      Z(T)    p·Z(H)     E[mX]       P할인  E[R_S]-R    -R·Cov
    0.3000    1.9841    0.5102    2.0833    0.5357    0.6250   11.9048    5.7143   -0.1300   -0.1300
    0.5000    1.1905    0.7143    1.2500    0.7500    0.6250   11.9048    9.5238   -0.0500   -0.0500
    0.7000    0.8503    1.1905    0.8929    1.2500    0.6250   11.9048   13.3333    0.0300    0.0300
    0.9000    0.6614    3.5714    0.6944    3.7500    0.6250   11.9048   17.1429    0.1100    0.1100

E[mX] 네 값 = [11.9048, 11.9048, 11.9048, 11.9048]  (표준편차 8.88e-16, max|E[mX]-V0| = 7.11e-15)
Q(H)=p·Z(H) 네 값 = [0.625, 0.625, 0.625, 0.625]  (q = 0.6250, max 차 = 0.00e+00)
P할인 네 값 = [5.71, 9.52, 13.33, 17.14]  (이웃 간 차 일정: [3.8095, 3.8095, 3.8095])
# 그림 1 — p를 연속으로 움직여 가격의 불변과 m의 상쇄를 겹쳐 본다.
pg = np.linspace(0.05, 0.95, 181)
fig, ax = plt.subplots(1, 2, figsize=(10.0, 3.6))
ax[0].axhline(price_Q, color='C3', lw=2, label='위험중립 가격 $R^{-1}E^Q[X]$')
ax[0].plot(pg, (pg * XH + (1 - pg) * XT) / R, color='C0', lw=2,
           label='실제 확률 할인 $R^{-1}E[X]$')
ax[0].scatter(ps, [(p * XH + (1 - p) * XT) / R for p in ps], color='C0', zorder=3)
ax[0].set_xlabel('$p$'); ax[0].set_ylabel('값'); ax[0].legend(fontsize=8)
ax[0].set_title('가격은 $p$에 불변, 실제 확률 할인은 선형')

ax[1].plot(pg, q / (pg * R), color='C2', lw=2, label='$m_H=q/(pR)$')
ax[1].plot(pg, (1 - q) / ((1 - pg) * R), color='C4', lw=2, label='$m_T=(1-q)/((1-p)R)$')
ax[1].axhline(1 / R, color='0.5', ls=':', label='$1/R$')
ax[1].axvline(q, color='0.5', ls='--')
ax[1].set_ylim(0, 4.0); ax[1].set_xlabel('$p$'); ax[1].set_ylabel('할인인자 $m$')
ax[1].legend(fontsize=8)
ax[1].set_title('$q$는 고정, $m$이 움직여 상쇄한다')
fig.tight_layout(); plt.show()
<Figure size 1000x360 with 2 Axes>
# N단계 나무를 직접 쌓는다 — 만기 마디 N+1개, 역방향 반복 N번((eq-w15-17)).
def tree_call(N, S0=100.0, K=100.0, sigma=0.2, r=0.03, Nh=1.0):
    h = Nh / N                                  # 만기는 Nh — 시각 T를 따로 두지 않는다
    uN, dN, RN = np.exp(sigma * np.sqrt(h)), np.exp(-sigma * np.sqrt(h)), np.exp(r * h)
    qN = (RN - dN) / (uN - dN)
    j = np.arange(N + 1)
    V = np.maximum(S0 * uN ** j * dN ** (N - j) - K, 0.0)
    for _ in range(N):
        V = (qN * V[1:] + (1 - qN) * V[:-1]) / RN
    return float(V[0])


# 참조값 — 답이 아니라 눈금이다. 표준정규 누적분포를 erf로 쌓는다.
def bs_call(S0=100.0, K=100.0, sigma=0.2, r=0.03, Nh=1.0):
    d1 = (np.log(S0 / K) + (r + sigma ** 2 / 2) * Nh) / (sigma * np.sqrt(Nh))
    d2 = d1 - sigma * np.sqrt(Nh)
    Phi = lambda x: 0.5 * (1.0 + erf(x / np.sqrt(2.0)))
    return float(S0 * Phi(d1) - K * np.exp(-r * Nh) * Phi(d2))


REF = bs_call()
print(f'참조값 = {REF:.4f}')
참조값 = 9.4134
# 예측 2 — N을 키우며 오차와 N·|오차|를 쌓는다.
Ns = [1, 10, 100, 1000, 50, 51]
print(f'{"N":>6}{"나무 가격":>12}{"오차":>12}{"N·|오차|":>12}{"부호":>6}{"홀짝":>6}')
for N in Ns:
    v = tree_call(N)
    e = v - REF
    print(f'{N:6d}{v:12.4f}{e:12.4f}{N * abs(e):12.4f}'
          f'{"+" if e > 0 else "-":>6}{"짝" if N % 2 == 0 else "홀":>6}')

# 짝수 N에서만 만기 마디가 K 위에 놓이는지 직접 확인한다.
for N in (50, 51):
    h = 1.0 / N                             # 만기 Nh = 1
    nodes = 100.0 * np.exp(0.2 * np.sqrt(h) * (2 * np.arange(N + 1) - N))
    print(f'N={N}: 만기 마디와 K=100의 최소 거리 = {np.abs(nodes - 100.0).min():.3e}')
     N       나무 가격          오차      N·|오차|    부호    홀짝
     1     11.2972      1.8838      1.8838     +     홀
    10      9.2179     -0.1956      1.9555     -     짝
   100      9.3936     -0.0198      1.9808     -     짝
  1000      9.4114     -0.0020      1.9830     -     짝
    50      9.3738     -0.0396      1.9782     -     짝
    51      9.4504      0.0370      1.8876     +     홀
N=50: 만기 마디와 K=100의 최소 거리 = 0.000e+00
N=51: 만기 마디와 K=100의 최소 거리 = 2.762e+00
# 그림 2 — log-log에서 오차 크기의 기울기를 재고 홀짝을 색으로 가른다.
Ng = np.arange(2, 201)
err = np.array([tree_call(int(N)) - REF for N in Ng])
slope = float(np.polyfit(np.log(Ng), np.log(np.abs(err)), 1)[0])

fig, ax = plt.subplots(figsize=(6.6, 3.9))
ax.loglog(Ng[err < 0], -err[err < 0], 'o', ms=3.2, color='C0', label='짝수 $N$ (오차 음)')
ax.loglog(Ng[err > 0], err[err > 0], 'o', ms=3.2, color='C3', label='홀수 $N$ (오차 양)')
ax.loglog(Ng, 1.9 / Ng, color='0.4', ls='--', lw=1.4, label='$1.9/N$')
plain = FuncFormatter(lambda v, _: f'{v:g}')      # 눈금을 한글 글꼴로 찍는다
for axis in (ax.xaxis, ax.yaxis):
    axis.set_major_formatter(plain); axis.set_minor_formatter(NullFormatter())
ax.set_xlabel('$N$'); ax.set_ylabel('|오차|')
ax.set_title(f'log-log 기울기 = {slope:.2f}, $N\\cdot|$오차$|\\approx 1.9$')
ax.legend(fontsize=8); fig.tight_layout(); plt.show()

print(f'log-log 기울기 = {slope:.3f}')
print(f'N·|오차| 중앙값 = {np.median(Ng * np.abs(err)):.3f}')
print(f'부호가 짝수 N에서 모두 음인가: {bool((err[Ng % 2 == 0] < 0).all())}')
print(f'부호가 홀수 N에서 모두 양인가: {bool((err[Ng % 2 == 1] > 0).all())}')
<Figure size 660x390 with 1 Axes>
log-log 기울기 = -0.998
N·|오차| 중앙값 = 1.915
부호가 짝수 N에서 모두 음인가: True
부호가 홀수 N에서 모두 양인가: True
# 예측 3 — 이항 확률질량을 이항계수로 직접 만들고 P·Q 두 저울로 잰다.
N, sigma, r, mu = 50, 0.2, 0.03, 0.08
h = 1.0 / N                                 # 만기 Nh = 1
uN, dN, RN = np.exp(sigma * np.sqrt(h)), np.exp(-sigma * np.sqrt(h)), np.exp(r * h)
qN = (RN - dN) / (uN - dN)
pN = (np.exp(mu * h) - dN) / (uN - dN)

k = np.arange(N + 1)
pmf_P = comb(N, k) * pN ** k * (1 - pN) ** (N - k)
pmf_Q = comb(N, k) * qN ** k * (1 - qN) ** (N - k)
logS = (2 * k - N) * sigma * np.sqrt(h)          # ln(S_N/S_0)

mean_P, mean_Q = float(pmf_P @ logS), float(pmf_Q @ logS)
var_P = float(pmf_P @ (logS - mean_P) ** 2)
var_Q = float(pmf_Q @ (logS - mean_Q) ** 2)
Z = (qN / pN) ** k * ((1 - qN) / (1 - pN)) ** (N - k)   # 경로별 밀도 dQ/dP
EZ = float(pmf_P @ Z)

print(f'q = {qN:.6f},  p = {pN:.6f}')
print(f'평균  P = {mean_P:.4f}   Q = {mean_Q:.4f}   이동 = {mean_P - mean_Q:.4f}'
      f'   ((mu-r)Nh = {(mu - r) * N * h:.4f})')
print(f'분산  P = {var_P:.4f}   Q = {var_Q:.4f}   차 = {abs(var_P - var_Q):.2e}')
print(f'E[Z] = {EZ:.4f},  Z 범위 = [{Z.min():.3f}, {Z.max():.3f}]')
q = 0.503538,  p = 0.521233
평균  P = 0.0601   Q = 0.0100   이동 = 0.0500   ((mu-r)Nh = 0.0500)
분산  P = 0.0399   Q = 0.0400   차 = 7.01e-05
E[Z] = 1.0000,  Z 범위 = [0.178, 6.139]
# 그림 3 — 두 저울 아래 로그수익률 분포를 겹친다.
bw = float(logS[1] - logS[0])
fig, ax = plt.subplots(figsize=(6.6, 3.9))
ax.bar(logS - 0.24 * bw, pmf_P, width=0.46 * bw, color='C0', alpha=0.8, label='$P$')
ax.bar(logS + 0.24 * bw, pmf_Q, width=0.46 * bw, color='C3', alpha=0.8, label='$Q$')
ax.axvline(mean_P, color='C0', ls='--', lw=1.4)
ax.axvline(mean_Q, color='C3', ls='--', lw=1.4)
ax.set_xlim(-0.55, 0.65)
ax.set_xlabel('$\\ln(S_N/S_0)$'); ax.set_ylabel('확률')
ax.set_title(f'평균 {mean_P:.3f} → {mean_Q:.3f} (이동 {mean_P - mean_Q:.3f}), '
             f'분산 {var_P:.3f} · {var_Q:.3f}')
ax.legend(fontsize=9); fig.tight_layout(); plt.show()
<Figure size 660x390 with 1 Axes>
# 대조 표에 옮길 수치를 예측 항목 순서로 모은다.
print('예측 1 — E[mX] ' + str([round(v, 4) for v in prices_m])
      + f' (표준편차 {np.std(prices_m):.1e}, max|E[mX]-V0| = '
      + f'{max(abs(v - V0) for v in prices_m):.1e}) / '
      + f'Q(H)=p·Z(H) = q = {q:.4f} (max 차 {max(abs(v - q) for v in QH):.1e}) / '
      + 'P할인 ' + str([round(v, 2) for v in prices_P])
      + f' / 프리미엄 {[round(row[8], 2) for row in rows]}')
vals = [tree_call(N) for N in (1, 10, 100, 1000)]
print('예측 2 — 가격 ' + str([round(v, 2) for v in vals])
      + f' / 참조 {REF:.2f} / 오차 ' + str([round(v - REF, 3) for v in vals])
      + f' / 기울기 {slope:.2f} / N=50 {tree_call(50) - REF:+.3f}, '
      f'N=51 {tree_call(51) - REF:+.3f}')
print(f'예측 3 — 평균 {mean_P:.3f} → {mean_Q:.3f} (이동 {mean_P - mean_Q:.3f}) / '
      f'분산 {var_P:.3f} · {var_Q:.3f} (차 {abs(var_P - var_Q):.1e}) / E[Z] = {EZ:.3f}')
예측 1 — E[mX] [11.9048, 11.9048, 11.9048, 11.9048] (표준편차 8.9e-16, max|E[mX]-V0| = 7.1e-15) / Q(H)=p·Z(H) = q = 0.6250 (max 차 0.0e+00) / P할인 [5.71, 9.52, 13.33, 17.14] / 프리미엄 [-0.13, -0.05, 0.03, 0.11]
예측 2 — 가격 [11.3, 9.22, 9.39, 9.41] / 참조 9.41 / 오차 [1.884, -0.196, -0.02, -0.002] / 기울기 -1.00 / N=50 -0.040, N=51 +0.037
예측 3 — 평균 0.060 → 0.010 (이동 0.050) / 분산 0.040 · 0.040 (차 7.0e-05) / E[Z] = 1.000

3. 대조

예측과 계산이 어긋난 지점을 적는다. 어느 쪽이 틀렸는지 판정한다.

예측결과어긋남원인

본문 확인: 어긋났으면 1번은 (eq-w15-9)·(eq-w15-13)·(eq-w15-18)로, 2번은 (eq-w15-15)·(eq-w15-17)로, 3번은 (eq-w15-16)으로 돌아간다.