Random Motion and Itô's Lemma
Question: if random changes average to zero, why can a curved transformation of those changes have a nonzero average change?
Imagine measuring the area of a square. Increasing its side length adds a slightly larger area than an equal decrease removes. The average side length can stay the same while the average area rises. This small curvature effect is the starting point for Itô's lemma: a rule for finding how a function changes when its input follows continuous random motion.
In Options and Replication, an asset had only two possible future values. We now build a model with many small random changes. We will explain the calculus beside the arithmetic, distinguish a simulated path from a forecast, and derive the correction needed when transforming a price into a log price.
All paths and parameters below are invented teaching inputs. Time is measured in years. The early examples use a dimensionless numerical input; the final application uses GBP per unit. Run cells in order. The grid-refinement experiment also runs independently.
import json
import numpy as np
import pandas as pd 1. Start with a Curve, Not a Stochastic Formula
Let
| Input | Change in input | Squared output | Change from 4 |
|---|---|---|---|
| 1.9 | −0.1 | 3.61 | −0.39 |
| 2.1 | +0.1 | 4.41 | +0.41 |
The average input is still 2. The average output is
Slope Measures the First Effect
A derivative measures the local slope: how much the output changes per small unit change in the input. Write
Divide by
The second derivative measures how the slope itself changes. Since
For a sufficiently smooth general function, a second-order Taylor expansion writes:
For a square this is exact; for other functions, higher-order terms remain. “First order” refers to a term proportional to
The graph shows the square curve and its tangent line at 2. The tangent is
centre = 2.0
changes = np.array([-0.1, 0.1])
inputs = centre + changes
exact_changes = inputs ** 2 - centre ** 2
linear_changes = 2 * centre * changes
curvature_changes = changes ** 2
curvature_table = pd.DataFrame({
"Input": inputs, "Squared output": inputs ** 2,
"Exact change": exact_changes, "Slope term": linear_changes,
"Curvature term": curvature_changes,
})
print(curvature_table.to_string(index=False, float_format=lambda value: f"{value:.4f}"))
print(f"Average input: {inputs.mean():.4f}; average squared output: {(inputs ** 2).mean():.4f}")
curve_x = np.linspace(1.5, 2.5, 101)
print("\n<ECHARTS_DATA>" + json.dumps({"itoCurvature": {
"type": "line", "title": "Equal Input Moves Do Not Cancel After Squaring",
"xAxis": {"type": "value", "name": "Input x (dimensionless)", "min": 1.5, "max": 2.5},
"yAxisName": "Squared output (dimensionless)",
"series": {"Square": np.column_stack([curve_x, curve_x ** 2]).tolist(),
"Tangent at 2": np.column_stack([curve_x, 4 + 4 * (curve_x - 2)]).tolist(),
"Two outcomes": np.column_stack([inputs, inputs ** 2]).tolist()},
"seriesOptions": {
"Tangent at 2": {"lineStyle": {"type": "dashed"}},
"Two outcomes": {"type": "scatter", "symbol": "diamond", "symbolSize": 18,
"itemStyle": {"color": "#ea580c", "borderColor": "#ffffff", "borderWidth": 2, "opacity": 1}},
},
}}, allow_nan=False)) Experiment: replace the two changes with [-0.2, 0.2]. Predict the average output before rerunning: the curvature addition becomes [-0.1, 0.1] afterwards.
Check: for the straight-line function
2. Random Changes Scale with the Square Root of Time
A Brownian motion, written
With time in years, Brownian motion has units of square-root years. Over one year its standard deviation is 1; over a quarter year it is 0.5. Over
Why not multiply the noise by
Simulate Grid Observations of Four Paths
The code uses a fixed random seed so you can reproduce the illustration. Each column is a separate path; rows are time increments. np.cumsum accumulates changes, and an initial zero row records the starting point.
The chart's horizontal axis is now elapsed time, unlike an option payoff graph. A line joins the simulated observations for readability. Those straight segments are drawing choices, not the true Brownian path between observations. Brownian paths are continuous but almost surely have no ordinary time derivative.
path_steps = 256
path_dt = 1 / path_steps
path_rng = np.random.default_rng(20261101)
path_increments = np.sqrt(path_dt) * path_rng.standard_normal((path_steps, 4))
brownian_paths = np.vstack([np.zeros(4), np.cumsum(path_increments, axis=0)])
path_times = np.linspace(0., 1., path_steps + 1)
print(f"Time interval: {path_dt:.8f} years; increment SD: {np.sqrt(path_dt):.4f} sqrt(years)")
print(f"A standardized shock of -1.2 gives: {-1.2 * np.sqrt(path_dt):.4f} sqrt(years)")
print("\n<ECHARTS_DATA>" + json.dumps({"itoBrownianPaths": {
"type": "line", "title": "Four Simulated Brownian Paths, All Starting at Zero",
"xAxis": {"type": "value", "name": "Elapsed time (years)"},
"yAxisName": "Brownian value W (square-root years)",
"series": {f"Path {i + 1}": np.column_stack([path_times, brownian_paths[:, i]]).tolist() for i in range(4)},
}}, allow_nan=False)) None of these paths is expected to sit at zero throughout the year. Zero is the mean over all possible paths at each date, not a restriction on an individual realization. Brownian motion itself is not an asset price: it can be negative and has different units.
Experiment: change path_steps to 64. The interval becomes four times longer, so the increment standard deviation doubles to 0.125. The theoretical one-year variance remains 1. Reseeding and changing the array shape generates a different path sample; it is not a refinement of the same four paths. Restore 256 before continuing.
Check: if the interval becomes four times shorter, what happens to a deterministic change proportional to time and to the noise standard deviation? The deterministic change is divided by four; the noise standard deviation is divided by two. The squared noise remains of the same order as elapsed time.
3. Add Squared Changes: Quadratic Variation
Ordinary changes can cancel. Squared changes cannot. For observations at dates
This has units of years. The quadratic variation is the limiting accumulated squared change as the largest interval shrinks. Brownian motion's quadratic variation over
First check a hand-built four-step illustration. Increments
For actual independent Gaussian increments on
For one year and 16 intervals, this is about 0.353553 years; for 4,096 intervals, about 0.022097 years. The normal fourth-moment identity
Observe One Path on Nested Grids
Generate 4,096 fine increments once. To obtain 16 coarse increments, sum consecutive groups of 256 fine increments. To obtain 64, sum groups of 64. Every grid ends at the same
The graph compares the sampled squared sum with the one-year target. For comparison with a smooth path, normalize Brownian values by the square root of one year and time by one year. Both plotted squared sums are then dimensionless; over this one-year horizon, the Brownian numbers are unchanged. The normalized smooth path
fine_steps = 4096
fine_rng = np.random.default_rng(20261101)
fine_increments = fine_rng.standard_normal(fine_steps) / np.sqrt(fine_steps)
grid_counts = np.array([16, 64, 256, 1024, 4096])
qv_rows = []
for count in grid_counts:
increments = fine_increments.reshape(count, -1).sum(axis=1)
qv_rows.append([count, increments.sum(), np.sum(increments ** 2), np.sqrt(2 / count), 1 / count])
qv_table = pd.DataFrame(qv_rows, columns=[
"Intervals", "Endpoint W", "Squared sum (years)", "Theoretical SD (years)", "Smooth squared sum",
])
print(qv_table.to_string(index=False, float_format=lambda value: f"{value:.6f}"))
print("\n<ECHARTS_DATA>" + json.dumps({"itoQuadraticVariation": {
"type": "line", "title": "Random Squared Changes Accumulate as the Grid Gets Finer",
"xAxis": {"type": "category", "name": "Number of intervals", "data": [str(n) for n in grid_counts]},
"yAxisName": "Normalized squared-increment sum (dimensionless)",
"series": {"Brownian squared sum": qv_table["Squared sum (years)"].tolist(),
"Brownian target": [1.] * len(grid_counts),
"Smooth path t": qv_table["Smooth squared sum"].tolist()},
"seriesOptions": {"Brownian target": {"lineStyle": {"type": "dashed"}}},
}}, allow_nan=False)) The sampled Brownian sum can move above or below 1 and need not improve at every refinement. A single path is an illustration of the theorem, not its proof. The variance calculation explains why the error shrinks in a probabilistic sense.
Experiment: replace the seed with 20261102. The endpoint and finite-grid errors change, but the target and theoretical standard deviations do not. Restore 20261101 and rerun before continuing.
Check: at 64 one-year intervals, the squared-sum standard deviation is
4. See Why the Ordinary Chain Rule Misses a Term
For a differentiable path, you may know the rule “change in the square equals twice the current value times its change.” For Brownian motion, discarding every squared change loses an amount that survives accumulation.
At each grid interval, ordinary algebra still gives the exact identity:
Add the intervals. Intermediate squared values cancel, leaving:
For our four hand-built increments, the values at the start of the intervals are
For a Brownian path,
The Itô integral here is the limit of the sums using each interval's starting value. That timing matters:
The next graph uses the 256-interval version of our fixed path. Compare the exact square, the first-order sum, and the first-order sum plus elapsed time. The last is the finite-grid Itô approximation. Adding the actual cumulative squared increments instead would reproduce the exact square at every grid point, up to rounding.
square_steps = 256
square_increments = fine_increments.reshape(square_steps, -1).sum(axis=1)
square_times = np.linspace(0., 1., square_steps + 1)
square_path = np.r_[0., np.cumsum(square_increments)]
square_exact = square_path ** 2
square_first_order = np.r_[0., np.cumsum(2 * square_path[:-1] * square_increments)]
square_qv = np.r_[0., np.cumsum(square_increments ** 2)]
square_ito = square_first_order + square_times
square_discrete = square_first_order + square_qv
print(f"Exact endpoint square: {square_exact[-1]:.6f} years")
print(f"First-order sum: {square_first_order[-1]:.6f} years")
print(f"Squared-increment sum: {square_qv[-1]:.6f} years")
print(f"Itô approximation endpoint error: {square_ito[-1] - square_exact[-1]:.6f} years")
print(f"Maximum exact discrete-identity error: {np.max(np.abs(square_discrete - square_exact)):.2e}")
print("\n<ECHARTS_DATA>" + json.dumps({"itoSquareIdentity": {
"type": "line", "title": "The Squared-Increment Term Cannot Be Dropped",
"xAxis": {"type": "value", "name": "Elapsed time (years)"},
"yAxisName": "Squared Brownian value or accumulated terms (years)",
"series": {"Exact square": np.column_stack([square_times, square_exact]).tolist(),
"First order only": np.column_stack([square_times, square_first_order]).tolist(),
"First order + time": np.column_stack([square_times, square_ito]).tolist()},
"seriesOptions": {"First order only": {"lineStyle": {"type": "dashed"}}},
}}, allow_nan=False)) The exact square is always nonnegative, but the first-order sum need not be. The time-corrected finite-grid approximation can also fall slightly below zero; it does not enforce positivity. Its signed endpoint error is
Experiment: set square_steps = 1024 and rerun this cell. You observe the same underlying fine sample at more dates. Check that the endpoint of the exact square is unchanged, the exact discrete identity still holds, and the approximation error equals
Check: why not use the average of the interval's starting and ending values? Algebra gives
5. Derive Itô's Formula for a General Diffusion
A diffusion combines a predictable local trend with Brownian noise. Write:
The coefficient
The differential notation describes a limiting model. A short-step numerical approximation is
Let
Substitute the state increment. Its square is:
As the grid is refined, the accumulated time-squared and time-times-noise terms vanish under the usual regularity conditions. The accumulated squared-noise term survives. For example, over one year,
For a diffusion with well-defined dynamics and a function with one continuous time derivative and two continuous state derivatives, Itô's formula is:
All coefficients and derivatives are evaluated at the current
Work Every Term in a Small Example
Use the dimensionless state
per year: the explicit effect of time. : the local slope. : the curvature. - The transformed drift is
per year. - The transformed noise coefficient is
per square-root year.
For
The bars compare direct substitution, slope-and-time only, and the Itô approximation for those two diagnostic shocks. They are not the whole Gaussian outcome distribution. Their average happens to match the exact Gaussian expectation here because a quadratic depends only on the first two moments.
state, drift, diffusion, time_coefficient = 2., 0.1, 0.3, 0.02
small_dt = 0.01
diagnostic_dw = np.array([-1., 1.]) * np.sqrt(small_dt)
state_change = drift * small_dt + diffusion * diagnostic_dw
transformed_exact = (state + state_change) ** 2 + time_coefficient * small_dt - state ** 2
transformed_linear = time_coefficient * small_dt + 2 * state * state_change
transformed_drift = time_coefficient + 2 * state * drift + diffusion ** 2
transformed_noise = 2 * state * diffusion
transformed_ito = transformed_drift * small_dt + transformed_noise * diagnostic_dw
exact_expected_change = transformed_drift * small_dt + drift ** 2 * small_dt ** 2
print(pd.DataFrame({"Brownian change": diagnostic_dw, "Direct change": transformed_exact,
"Slope + time": transformed_linear, "Itô approximation": transformed_ito}).to_string(index=False))
print(f"Transformed drift: {transformed_drift:.6f} per year; noise coefficient: {transformed_noise:.6f} per sqrt(year)")
print(f"Exact conditional expected change: {exact_expected_change:.6f}; Itô leading term: {transformed_drift * small_dt:.6f}")
print("\n<ECHARTS_DATA>" + json.dumps({"itoLocalTransformation": {
"type": "bar", "title": "Account for Time, Slope, and Curvature",
"xAxis": {"type": "category", "name": "Illustrative standardized shock", "data": ["Z = -1", "Z = +1"]},
"yAxisName": "Change in F (dimensionless)",
"series": {"Direct substitution": transformed_exact.tolist(), "Slope + time": transformed_linear.tolist(),
"Itô approximation": transformed_ito.tolist()},
}}, allow_nan=False)) Experiment: set diffusion = 0.0. The curvature drift disappears, and both diagnostic outcomes become the same deterministic change. The small residual
Check: if diffusion doubles to 0.6, does its curvature contribution double? No:
6. Experiment: Refine the Grid on the Same Random Path
The slider chooses the grid power 20261101 and groups them exactly as in the visible calculations above.
The first graph compares the exact square, the first-order sum, and the time-corrected approximation. The second compares accumulated squared increments with elapsed time. Their endpoint differences are linked: the first graph's corrected error is
Blue shows the exact or sampled quantity; orange shows the time-based comparison; grey dashes show the first-order-only calculation. Refining the grid reveals more of the same sample. It changes neither the final Brownian value nor the exact final square.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_ito_refinement_echart,
grid_power=widgets.IntSlider(value=8, min=4, max=12, step=1,
description="Grid power", continuous_update=False),
)
widget_instance Try it: compare powers 4, 8, 10, and 12. Predict which outputs stay fixed before moving the control. The exact discrete identity should stay near machine precision; the time-corrected approximation generally improves at finer scales, but its error need not decrease at every step.
Check: if the final squared sum is 0.98, is the corrected square too high or too low? It is too high by
7. Prepare the Price Application: Log Returns and Continuous Rates
We now return to money. A price rising from £100 to £110 has a simple return of
Taking the logarithm of a ratio keeps its argument dimensionless. Log returns add across adjacent periods: a move from £100 to £110 and then £121 has total log return
Our earlier financing examples used an effective annual rate
The graph compares simple and log returns for final prices between £60 and £140 from an initial £100. Both are zero at £100. The log curve lies below the simple-return line because logarithms are concave. We exclude zero and negative prices from this calculation because their real logarithms are not defined.
return_prices = np.linspace(60., 140., 81)
simple_returns = return_prices / 100 - 1
log_returns = np.log(return_prices / 100)
effective_rate = 0.05
continuous_rate = np.log1p(effective_rate)
print(f"100 to 110: simple return {100 * (110 / 100 - 1):.4f}%; log return {100 * np.log(110 / 100):.4f}%")
print(f"Equivalent continuous rate: {continuous_rate:.8f} per year")
print(f"Half-year growth: {(1 + effective_rate) ** 0.5:.8f}; exponential form: {np.exp(continuous_rate * 0.5):.8f}")
print("\n<ECHARTS_DATA>" + json.dumps({"itoLogReturns": {
"type": "line", "title": "Simple and Log Returns Measure the Same Move Differently",
"xAxis": {"type": "value", "name": "Final asset price (GBP/unit)", "min": 60, "max": 140},
"yAxisName": "Return from an initial 100 GBP (%)",
"series": {"Simple return": np.column_stack([return_prices, 100 * simple_returns]).tolist(),
"Log return": np.column_stack([return_prices, 100 * log_returns]).tolist()},
}}, allow_nan=False)) Experiment: calculate a fall from £100 to £90. Its simple return is −10%; its log return is approximately −10.5361%. Equal-sized simple gains and losses are not offsetting log returns.
Check: a 10% rise followed by a 10% fall finishes at £99. The total simple return is −1%; the total log return is
8. Apply Itô to a Positive Asset Price
Assume a non-income-paying asset follows geometric Brownian motion:
At a price of £100, the local price drift is
Calculate the Derivatives of the Logarithm
For
Substitute
Numerically, the log drift is
Because the coefficients are constant, accumulating over time gives:
For a one-year Brownian outcome
Separate the Mean Price from the Median Price
Under this model, the mean price at time
The median corresponds to the median Brownian value, zero. The mean uses the normal exponential-average identity
The graph uses our fixed 256-interval Brownian sample. It compares the exact model price with the deliberately incorrect expression
initial_price, mu, sigma = 100., 0.08, 0.20
price_times = square_times
price_brownian = square_path
log_drift = mu - 0.5 * sigma ** 2
gbm_prices = initial_price * np.exp(log_drift * price_times + sigma * price_brownian)
uncorrected_prices = initial_price * np.exp(mu * price_times + sigma * price_brownian)
mean_prices = initial_price * np.exp(mu * price_times)
median_prices = initial_price * np.exp(log_drift * price_times)
print(f"Price drift: {mu:.4f} per year; log drift: {log_drift:.4f} per year")
print(f"One-year mean: {mean_prices[-1]:.6f} GBP; median: {median_prices[-1]:.6f} GBP")
print(f"One-year expected simple return: {100 * np.expm1(mu):.6f}%")
print(f"For W(1) = 0.5: {initial_price * np.exp(log_drift + sigma * 0.5):.6f} GBP")
print(f"Omitting correction multiplies the final price by: {uncorrected_prices[-1] / gbm_prices[-1]:.6f}")
print("\n<ECHARTS_DATA>" + json.dumps({"itoLogPrice": {
"type": "line", "title": "The Log-Drift Correction Preserves the Intended Price Model",
"xAxis": {"type": "value", "name": "Elapsed time (years)"},
"yAxisName": "Asset price (GBP/unit)",
"yAxis": {"type": "value", "name": "Asset price (GBP/unit)", "scale": True},
"series": {"Exact GBM path": np.column_stack([price_times, gbm_prices]).tolist(),
"Correction omitted": np.column_stack([price_times, uncorrected_prices]).tolist(),
"Model mean": np.column_stack([price_times, mean_prices]).tolist(),
"Model median": np.column_stack([price_times, median_prices]).tolist()},
"seriesOptions": {"Correction omitted": {"lineStyle": {"type": "dashed"}},
"Model mean": {"lineStyle": {"type": "dotted"}},
"Model median": {"lineStyle": {"type": "dashed"}}},
}}, allow_nan=False)) Experiment: double sigma to 0.40 while holding mu fixed. The log drift becomes sigma = 0.0 to check that the random path, mean, median, and uncorrected curve all coincide; then restore 0.20.
Check: does the 0.02 correction represent a fee or cash taken from an account? No. It comes from transforming a random price with a concave function. It reconciles price growth and log-price growth under the same model.
The
9. Worked Exercise: Half a Year with a Different Volatility
Take a price of £80, instantaneous price drift
- Calculate the log drift and expected log return over the half-year.
- If
square-root years, calculate the realized log return and final price. - Calculate the model mean and median final prices. Is the realized price required to equal either?
- With 64 equal intervals over this half-year, find the expected squared-increment sum and its standard deviation. If the sample sum is 0.54 years, what is the endpoint error when approximating
by the first-order sum plus time?
Hint
Subtract half of the squared volatility from the price drift, and multiply that log drift by elapsed years. The Brownian outcome already includes its time scaling: multiply it by volatility, without multiplying by another square root of time. For quadratic variation, keep the horizon
Worked answer
The log drift is
The realized log return is
The mean is
The expected squared sum is 0.5 years and its standard deviation is
exercise_initial, exercise_mu, exercise_sigma = 80., 0.06, 0.30
exercise_time, exercise_w, exercise_count = 0.5, 0.2, 64
exercise_log_drift = exercise_mu - 0.5 * exercise_sigma ** 2
exercise_expected_log = exercise_log_drift * exercise_time
exercise_realized_log = exercise_expected_log + exercise_sigma * exercise_w
exercise_price = exercise_initial * np.exp(exercise_realized_log)
exercise_mean = exercise_initial * np.exp(exercise_mu * exercise_time)
exercise_median = exercise_initial * np.exp(exercise_expected_log)
exercise_qv_sd = exercise_time * np.sqrt(2 / exercise_count)
print(f"Log drift: {exercise_log_drift:.6f} per year; expected log return: {exercise_expected_log:.6f}")
print(f"Realized log return: {exercise_realized_log:.6f}; final price: {exercise_price:.6f} GBP")
print(f"Mean: {exercise_mean:.6f} GBP; median: {exercise_median:.6f} GBP")
print(f"Expected squared sum: {exercise_time:.6f} years; SD: {exercise_qv_sd:.6f} years")
print(f"Corrected-square error if squared sum is 0.54: {exercise_time - 0.54:.6f} years") Chapter Summary
- A curved function can turn zero-mean input changes into a nonzero average output change. The second derivative measures this effect locally.
- Brownian increments have standard deviation proportional to the square root of elapsed time. Their accumulated squares approach elapsed time rather than zero.
- Itô integrals use information at the start of each interval. Sampling conventions matter.
- Itô's formula combines explicit time change, the ordinary slope effect, and a curvature term equal to half the local variance rate times the second derivative.
- For geometric Brownian motion, log drift is price drift minus half the squared volatility. Mean price, median price, expected log return, and realized price answer different questions.
Continue with Deriving and Using Black–Scholes. Use these derivatives inside a fully accounted-for hedge, then derive and calculate European option prices.
