只能上涨或下跌的股票
布莱克-斯科尔斯是一个公式,而二叉树是一套步骤,它能为公式无法处理的东西定价。把到期时间切成很多小步。每一步股价要么乘以 u 上涨,要么乘以 d 下跌,别无其他。走 n 步后有 n + 1 个可能的价格。到期时的收益已知,更早的每个节点的价值是它所通向的两个节点的折现平均,因此可以从后往前把树填到现在。
# One step: a stock at 100 goes to 110 or 90. What is a call with strike 100 worth?
import math
spot, strike, rate, dt = 100, 100, 0.05, 1.0
u, d = 1.10, 0.90
p = (math.exp(rate * dt) - d) / (u - d) # the probability that makes the stock grow at the rate
up_value, down_value = max(spot * u - strike, 0), max(spot * d - strike, 0)
price = math.exp(-rate * dt) * (p * up_value + (1 - p) * down_value)
print(round(p, 4), round(up_value, 4), down_value) # 0.7564 10.0 0
print(round(price, 4)) # 7.1947这个概率不是预测
数 p 的选法使股票平均以无风险利率增长:p * u + (1 - p) * d = exp(rate * dt),由此 p = (exp(rate * dt) - d) / (u - d)。它是风险中性概率:一种让复制组合等于其折现期望的手段,而不是对股价走向的看法。为了匹配观察到的波动率,Cox–Ross–Rubinstein(CRR)树取 u = exp(sigma * sqrt(dt)) 和 d = 1 / u,于是先涨后跌会回到同一价格,树可以重合:n 步在末端只有 n + 1 个节点。
# The CRR parameters and the prices at expiry of a 3-step tree
import math
def crr_parameters(rate, sigma, t, steps):
dt = t / steps
u = math.exp(sigma * math.sqrt(dt))
d = 1 / u
p = (math.exp(rate * dt) - d) / (u - d)
return u, d, p
def terminal_prices(spot, u, d, steps):
return [spot * u ** j * d ** (steps - j) for j in range(steps + 1)]
u, d, p = crr_parameters(0.05, 0.2, 1, 3)
print(round(u, 4), round(d, 4), round(p, 4)) # 1.1224 0.8909 0.5438
print([round(price, 2) for price in terminal_prices(100, u, d, 3)])倒推
到期时每个节点就是收益,看涨期权为 max(S - K, 0)。往前一列,每个值是 exp(-rate * dt) * (p * value_up + (1 - p) * value_down),这一列少一个节点。经过 n 次扫描后只剩一个值,就是今天的价格。在 Python 中,一列就是一个列表,一次扫描就是对相邻两项的列表推导。随着步数增加,价格收敛到布莱克-斯科尔斯价格,二叉树复现了公式的结果。
# The tree price of a European call approaches the Black-Scholes price as steps grow
import math
def norm_cdf(x):
return 0.5 * (1 + math.erf(x / math.sqrt(2)))
def bs_call(spot, strike, rate, sigma, t):
d1 = (math.log(spot / strike) + (rate + 0.5 * sigma ** 2) * t) / (sigma * math.sqrt(t))
return spot * norm_cdf(d1) - strike * math.exp(-rate * t) * norm_cdf(d1 - sigma * math.sqrt(t))
def european_call_tree(spot, strike, rate, sigma, t, steps):
dt = t / steps
u = math.exp(sigma * math.sqrt(dt))
d = 1 / u
p = (math.exp(rate * dt) - d) / (u - d)
disc = math.exp(-rate * dt)
values = [max(spot * u ** j * d ** (steps - j) - strike, 0) for j in range(steps + 1)]
for _ in range(steps):
values = [disc * (p * values[j + 1] + (1 - p) * values[j]) for j in range(len(values) - 1)]
return values[0]
print("Black-Scholes", round(bs_call(100, 100, 0.05, 0.2, 1), 4))
for steps in (1, 5, 25, 100, 400):
print(steps, round(european_call_tree(100, 100, 0.05, 0.2, 1, steps), 4))提前行权
二叉树真正的长处是公式无法定价的期权:美式期权可以在任何时候行权。在每个节点,持有人比较两个价值——继续持有的折现期望,和立即行权的收益——并取较大者。算法的全部改动就是每个节点多一个 max。对于不分红股票的看涨期权,提前行权永远不划算,但看跌期权可能划算:深度实值的看跌期权今天拿现金更值钱,所以美式看跌期权比欧式的贵。
# An American put against a European put: the early-exercise premium
import math
def tree(spot, strike, rate, sigma, t, steps, american):
dt = t / steps
u = math.exp(sigma * math.sqrt(dt))
d = 1 / u
p = (math.exp(rate * dt) - d) / (u - d)
disc = math.exp(-rate * dt)
values = [max(strike - spot * u ** j * d ** (steps - j), 0) for j in range(steps + 1)]
for i in range(steps - 1, -1, -1):
values = [disc * (p * values[j + 1] + (1 - p) * values[j]) for j in range(i + 1)]
if american:
values = [max(values[j], strike - spot * u ** j * d ** (i - j)) for j in range(i + 1)]
return values[0]
for spot in (80, 100, 120):
european = tree(spot, 100, 0.05, 0.2, 1, 200, False)
american = tree(spot, 100, 0.05, 0.2, 1, 200, True)
print(spot, round(european, 4), round(american, 4), "premium", round(american - european, 4))n 步大约需要 n**2 / 2 次节点更新,而且收敛并不平滑(误差会随行权价落在节点之间的位置而摆动),所以要用几个不同的步数来比较检验。- 每一步股价乘以
u或d;CRR 的取法u = exp(sigma * sqrt(dt))、d = 1 / u使树重合,n步只有n + 1个末端节点。 - 倒推定价:先取到期收益,再对每个节点算
exp(-rate * dt) * (p * up + (1 - p) * down);p是风险中性概率,不是预测。 - 步数多了会收敛到布莱克-斯科尔斯;美式期权只是在每个节点多一个
max(继续持有, 行权),这正是二叉树存在的理由。
下一节:有限差分法,在网格上求解期权的偏微分方程来定价。
实验:评分内容
- 用
crr_parameters确定上涨与下跌幅度25 分 - 用
terminal_prices列出到期时的价格25 分 - 用
european_call_tree沿二叉树倒推25 分 - 用
american_put_tree允许提前行权25 分