Bull Call Spread — Code Walkthrough
Walk through the CRR binomial tree pricing model, how Greeks are computed via finite differences, and how the spread P&L is assembled from two option legs.
Bull Call Spread — Code Walkthrough
This is the most mathematically rich strategy in the library. It prices options using a Cox-Ross-Rubinstein (CRR) binomial tree — a discrete-time model that converges to Black-Scholes as steps → ∞ but correctly handles American early-exercise.
Step 1 — CRR Tree Parameters
export function crrPrice(inputs: CRRInputs): number {
const { spotPrice: S, strikePrice: K, timeToExpiry: T,
riskFreeRate: r, volatility: sigma,
optionType, optionStyle = 'american', steps: n = 100 } = inputs;
if (T <= 0 || sigma <= 0) {
// At/past expiry: return intrinsic value only
return optionType === 'call' ? Math.max(S - K, 0) : Math.max(K - S, 0);
}
const dt = T / n; // length of one time step (years)
const u = Math.exp(sigma * Math.sqrt(dt)); // up-move factor
const d = 1 / u; // down-move factor (1/u = recombining)
const disc = Math.exp(-r * dt); // risk-free discount per step
const p = (Math.exp(r * dt) - d) / (u - d);// risk-neutral up-probability
}| Variable | Formula | Intuition |
|---|---|---|
| u (up factor) | e^(σ√Δt) | How much stock rises in an up-move. Higher vol → bigger moves. |
| d (down factor) | 1/u | Ensures the tree recombines: up then down = down then up. |
| disc (discount) | e^(−rΔt) | Present value factor for one time step. |
| p (risk-neutral prob) | (e^(rΔt) − d) / (u − d) | Probability that makes the tree arbitrage-free. Not the real-world probability. |
Step 2 — Terminal Payoffs (Forward Pass)
// At expiry (step n), node j has stock price S × u^j × d^(n-j)
// There are n+1 terminal nodes: j = 0 (all down), 1, 2, ..., n (all up)
const values: number[] = Array.from({ length: n + 1 }, (_, j) => {
const spotT = S * Math.pow(u, j) * Math.pow(d, n - j);
return optionType === 'call'
? Math.max(spotT - K, 0) // call payoff
: Math.max(K - spotT, 0); // put payoff
});This array has n+1 elements indexed by number of up-moves j. Because d = 1/u, the tree recombines: an up-then-down path lands at the same price as a down-then-up path. Without this, you'd need 2^n nodes instead of (n+1), making n=100 feasible.
Step 3 — Backward Induction (American Early Exercise)
// Walk backward from expiry to today
for (let i = n - 1; i >= 0; i--) {
for (let j = 0; j <= i; j++) {
// Continuation value: expected discounted payoff if we wait
const continuation = disc * (p * values[j + 1] + (1 - p) * values[j]);
if (optionStyle === 'american') {
// Early exercise value at this node
const spotIJ = S * Math.pow(u, j) * Math.pow(d, i - j);
const intrinsic = optionType === 'call'
? Math.max(spotIJ - K, 0)
: Math.max(K - spotIJ, 0);
// American: holder chooses the better of exercise now vs wait
values[j] = Math.max(intrinsic, continuation);
} else {
values[j] = continuation; // European: can't exercise early
}
}
}
return Math.round(values[0] * 1e6) / 1e6; // values[0] = today's fair priceWhy American Options Need the Tree
Black-Scholes has a closed-form solution for European options but not American ones. The tree evaluates "should I exercise today?" at every node — this is the key advantage of the binomial model. For American puts especially, early exercise can be optimal when deep in-the-money.
Step 4 — Greeks via Finite Differences
export function crrGreeks(inputs: CRRInputs): Greeks {
const { spotPrice: S, volatility: sigma, riskFreeRate: r, timeToExpiry: T } = inputs;
const dS = S * 0.01; // bump spot by 1%
const base = crrPrice(inputs); // base price
const pu = crrPrice({ ...inputs, spotPrice: S + dS }); // price if spot +1%
const pd = crrPrice({ ...inputs, spotPrice: S - dS }); // price if spot -1%
return {
// Delta: ∂Price/∂Spot (central difference, most accurate)
delta: (pu - pd) / (2 * dS),
// Gamma: ∂²Price/∂Spot² (second derivative)
gamma: (pu - 2 * base + pd) / dS ** 2,
// Theta: ∂Price/∂Time (how much value lost per day)
theta: (crrPrice({ ...inputs, timeToExpiry: T - 1/365 }) - base) / (1/365) / 365,
// Vega: ∂Price/∂Volatility per 1% vol change
vega: (crrPrice({ ...inputs, volatility: sigma + 0.01 }) -
crrPrice({ ...inputs, volatility: sigma - 0.01 })) / (2 * 0.01) / 100,
};
}Central finite differences are used for Delta and Gamma because they're more accurate than one-sided differences (error is O(h²) vs O(h)). Each Greek is just the option price sensitivity to one input — by bumping that input slightly and re-pricing the full tree.
Step 5 — Assembling the Spread
// Buy lower-strike call (long), sell higher-strike call (short)
const longCallPrice = crrPrice(base(lowStrike)); // costs money
const shortCallPrice = crrPrice(base(highStrike)); // earns money
const netDebit = (longCallPrice - shortCallPrice) * contracts * 100;
const spreadWidth = (highStrike - lowStrike) * contracts * 100;
const maxProfit = spreadWidth - netDebit; // capped at highStrike
const maxLoss = netDebit; // limited to what you paid
const breakeven = lowStrike + netDebit / (contracts * 100);| Scenario at Expiry | P&L | Why |
|---|---|---|
| Stock below low strike | −netDebit (max loss) | Both calls expire worthless |
| Stock at breakeven | $0 | Long call gain equals net debit paid |
| Stock above high strike | +maxProfit (capped) | Long call fully profitable, short call fully losses — net is spread width minus debit |
- CRR builds a recombining lattice of stock prices and backward-inducts option values
- American options can exercise early — the tree checks intrinsic value at every node
- Greeks are finite differences: bump one input, re-price, take the derivative
- A bull call spread costs less than a naked call but caps upside at the short strike