Skip to main content

proof_engine/stochastic/
geometric_bm.rs

1//! Geometric Brownian Motion (GBM) for modelling stock prices and
2//! multiplicative stochastic processes.
3//!
4//! dS = mu * S * dt + sigma * S * dW
5//! Closed-form: S(t) = S(0) * exp((mu - sigma²/2)*t + sigma * W(t))
6
7use super::brownian::Rng;
8use glam::Vec2;
9
10// ---------------------------------------------------------------------------
11// GeometricBM
12// ---------------------------------------------------------------------------
13
14/// Geometric Brownian Motion parameters.
15pub struct GeometricBM {
16    /// Drift rate (annualised expected return).
17    pub mu: f64,
18    /// Volatility (annualised standard deviation).
19    pub sigma: f64,
20    /// Initial value S(0).
21    pub s0: f64,
22    /// Time step size.
23    pub dt: f64,
24}
25
26impl GeometricBM {
27    pub fn new(mu: f64, sigma: f64, s0: f64, dt: f64) -> Self {
28        Self { mu, sigma, s0, dt }
29    }
30
31    /// Single step: advance from `current` to next value.
32    /// S(t+dt) = S(t) * exp((mu - sigma²/2)*dt + sigma*sqrt(dt)*Z)
33    pub fn step(&self, rng: &mut Rng, current: f64) -> f64 {
34        let z = rng.normal();
35        let drift = (self.mu - 0.5 * self.sigma * self.sigma) * self.dt;
36        let diffusion = self.sigma * self.dt.sqrt() * z;
37        current * (drift + diffusion).exp()
38    }
39
40    /// Generate a full price path of length `steps + 1`.
41    pub fn path(&self, rng: &mut Rng, steps: usize) -> Vec<f64> {
42        let mut prices = Vec::with_capacity(steps + 1);
43        prices.push(self.s0);
44        let mut current = self.s0;
45        for _ in 0..steps {
46            current = self.step(rng, current);
47            prices.push(current);
48        }
49        prices
50    }
51
52    /// Expected value at time t: E[S(t)] = S(0) * exp(mu * t).
53    pub fn expected_value(&self, t: f64) -> f64 {
54        self.s0 * (self.mu * t).exp()
55    }
56
57    /// Variance at time t: Var[S(t)] = S(0)² * exp(2*mu*t) * (exp(sigma²*t) - 1).
58    pub fn variance(&self, t: f64) -> f64 {
59        let s0_sq = self.s0 * self.s0;
60        s0_sq * (2.0 * self.mu * t).exp() * ((self.sigma * self.sigma * t).exp() - 1.0)
61    }
62
63    /// Generate multiple independent paths (e.g. for Monte Carlo pricing).
64    pub fn paths(&self, rng: &mut Rng, steps: usize, count: usize) -> Vec<Vec<f64>> {
65        (0..count).map(|_| self.path(rng, steps)).collect()
66    }
67}
68
69// ---------------------------------------------------------------------------
70// Black-Scholes option pricing
71// ---------------------------------------------------------------------------
72
73/// Cumulative distribution function of the standard normal (approximation).
74fn normal_cdf(x: f64) -> f64 {
75    // Abramowitz and Stegun 7.1.26 (an erf approximation, max error 1.5e-7)
76    let a1 = 0.254829592;
77    let a2 = -0.284496736;
78    let a3 = 1.421413741;
79    let a4 = -1.453152027;
80    let a5 = 1.061405429;
81    let p = 0.3275911;
82
83    // These coefficients approximate erf(z) = 1 - poly(t) e^(-z^2) (A&S
84    // 7.1.26), and the normal CDF is 0.5 * (1 + erf(x / sqrt 2)). The code
85    // fed x itself into the erf formula with e^(-x^2 / 2), a mix of the two,
86    // so every Black-Scholes price was off (11.91 instead of 10.45 for the
87    // textbook at-the-money call).
88    let sign = if x < 0.0 { -1.0 } else { 1.0 };
89    let x_abs = x.abs() / std::f64::consts::SQRT_2;
90    let t = 1.0 / (1.0 + p * x_abs);
91    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x_abs * x_abs).exp();
92
93    0.5 * (1.0 + sign * y)
94}
95
96/// Black-Scholes European call option price.
97///
98/// * `s` - Current stock price
99/// * `k` - Strike price
100/// * `r` - Risk-free interest rate (annualised)
101/// * `sigma` - Volatility (annualised)
102/// * `t` - Time to expiration (years)
103pub fn black_scholes_call(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> f64 {
104    if t <= 0.0 {
105        return (s - k).max(0.0);
106    }
107    let d1 = ((s / k).ln() + (r + 0.5 * sigma * sigma) * t) / (sigma * t.sqrt());
108    let d2 = d1 - sigma * t.sqrt();
109    s * normal_cdf(d1) - k * (-r * t).exp() * normal_cdf(d2)
110}
111
112/// Black-Scholes European put option price.
113///
114/// * `s` - Current stock price
115/// * `k` - Strike price
116/// * `r` - Risk-free interest rate (annualised)
117/// * `sigma` - Volatility (annualised)
118/// * `t` - Time to expiration (years)
119pub fn black_scholes_put(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> f64 {
120    if t <= 0.0 {
121        return (k - s).max(0.0);
122    }
123    let d1 = ((s / k).ln() + (r + 0.5 * sigma * sigma) * t) / (sigma * t.sqrt());
124    let d2 = d1 - sigma * t.sqrt();
125    k * (-r * t).exp() * normal_cdf(-d2) - s * normal_cdf(-d1)
126}
127
128/// Compute the implied volatility for a call option using bisection.
129pub fn implied_volatility_call(s: f64, k: f64, r: f64, t: f64, market_price: f64) -> f64 {
130    let mut lo = 0.001;
131    let mut hi = 5.0;
132    for _ in 0..100 {
133        let mid = (lo + hi) / 2.0;
134        let price = black_scholes_call(s, k, r, mid, t);
135        if price < market_price {
136            lo = mid;
137        } else {
138            hi = mid;
139        }
140    }
141    (lo + hi) / 2.0
142}
143
144/// Greeks for a European call option.
145pub struct Greeks {
146    pub delta: f64,
147    pub gamma: f64,
148    pub theta: f64,
149    pub vega: f64,
150    pub rho: f64,
151}
152
153/// Compute Greeks for a European call.
154pub fn call_greeks(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> Greeks {
155    let sqrt_t = t.sqrt();
156    let d1 = ((s / k).ln() + (r + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
157    let d2 = d1 - sigma * sqrt_t;
158    let pdf_d1 = (-0.5 * d1 * d1).exp() / (2.0 * std::f64::consts::PI).sqrt();
159
160    let delta = normal_cdf(d1);
161    let gamma = pdf_d1 / (s * sigma * sqrt_t);
162    let theta = -(s * pdf_d1 * sigma) / (2.0 * sqrt_t) - r * k * (-r * t).exp() * normal_cdf(d2);
163    let vega = s * pdf_d1 * sqrt_t;
164    let rho = k * t * (-r * t).exp() * normal_cdf(d2);
165
166    Greeks { delta, gamma, theta, vega, rho }
167}
168
169// ---------------------------------------------------------------------------
170// GBMRenderer
171// ---------------------------------------------------------------------------
172
173/// Render GBM price paths as glyph line charts.
174pub struct GBMRenderer {
175    pub character: char,
176    pub color: [f32; 4],
177    pub x_scale: f32,
178    pub y_scale: f32,
179}
180
181impl GBMRenderer {
182    pub fn new() -> Self {
183        Self {
184            character: '█',
185            color: [0.2, 1.0, 0.3, 1.0],
186            x_scale: 0.05,
187            y_scale: 0.01,
188        }
189    }
190
191    pub fn with_scales(mut self, x_scale: f32, y_scale: f32) -> Self {
192        self.x_scale = x_scale;
193        self.y_scale = y_scale;
194        self
195    }
196
197    /// Render a single price path as positioned glyphs.
198    pub fn render_path(&self, path: &[f64]) -> Vec<(Vec2, char, [f32; 4])> {
199        path.iter()
200            .enumerate()
201            .map(|(i, &price)| {
202                let pos = Vec2::new(i as f32 * self.x_scale, price as f32 * self.y_scale);
203                (pos, self.character, self.color)
204            })
205            .collect()
206    }
207
208    /// Render multiple paths with varying alpha for a fan chart effect.
209    pub fn render_fan(&self, paths: &[Vec<f64>]) -> Vec<(Vec2, char, [f32; 4])> {
210        let n = paths.len().max(1);
211        let mut glyphs = Vec::new();
212        for (pi, path) in paths.iter().enumerate() {
213            let alpha = 0.1 + 0.3 * (pi as f32 / n as f32);
214            let color = [self.color[0], self.color[1], self.color[2], alpha];
215            for (i, &price) in path.iter().enumerate() {
216                let pos = Vec2::new(i as f32 * self.x_scale, price as f32 * self.y_scale);
217                glyphs.push((pos, self.character, color));
218            }
219        }
220        glyphs
221    }
222}
223
224impl Default for GBMRenderer {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230// ---------------------------------------------------------------------------
231// Tests
232// ---------------------------------------------------------------------------
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_gbm_always_positive() {
240        let gbm = GeometricBM::new(0.05, 0.2, 100.0, 0.01);
241        let mut rng = Rng::new(42);
242        let path = gbm.path(&mut rng, 1000);
243        assert!(path.iter().all(|&p| p > 0.0), "GBM should always be positive");
244    }
245
246    #[test]
247    fn test_gbm_expected_value() {
248        // E[S(t)] = S0 * exp(mu*t)
249        let mu = 0.05;
250        let sigma = 0.3;
251        let s0 = 100.0;
252        let dt = 0.001;
253        let steps = 1000; // t = 1.0
254        let trials = 5000;
255        let gbm = GeometricBM::new(mu, sigma, s0, dt);
256        let mut rng = Rng::new(12345);
257
258        let sum: f64 = (0..trials)
259            .map(|_| {
260                let path = gbm.path(&mut rng, steps);
261                *path.last().unwrap()
262            })
263            .sum();
264        let empirical_mean = sum / trials as f64;
265        let expected = s0 * (mu * 1.0).exp(); // ~105.13
266
267        assert!(
268            (empirical_mean - expected).abs() / expected < 0.1,
269            "empirical mean {} should be close to expected {}",
270            empirical_mean,
271            expected
272        );
273    }
274
275    #[test]
276    fn test_gbm_log_normal() {
277        // ln(S(t)/S(0)) should be normally distributed with
278        // mean (mu - sigma²/2)*t and variance sigma²*t
279        let mu = 0.1;
280        let sigma = 0.2;
281        let s0 = 100.0;
282        let dt = 0.01;
283        let steps = 100; // t = 1.0
284        let trials = 10_000;
285        let gbm = GeometricBM::new(mu, sigma, s0, dt);
286        let mut rng = Rng::new(777);
287
288        let log_returns: Vec<f64> = (0..trials)
289            .map(|_| {
290                let path = gbm.path(&mut rng, steps);
291                (path.last().unwrap() / s0).ln()
292            })
293            .collect();
294
295        let mean = log_returns.iter().sum::<f64>() / trials as f64;
296        let var = log_returns.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / trials as f64;
297        let expected_mean = (mu - 0.5 * sigma * sigma) * 1.0;
298        let expected_var = sigma * sigma * 1.0;
299
300        assert!(
301            (mean - expected_mean).abs() < 0.05,
302            "log-return mean {} should be ~{}",
303            mean,
304            expected_mean
305        );
306        assert!(
307            (var - expected_var).abs() < 0.02,
308            "log-return variance {} should be ~{}",
309            var,
310            expected_var
311        );
312    }
313
314    #[test]
315    fn test_black_scholes_put_call_parity() {
316        // C - P = S - K*exp(-rT)
317        let s = 100.0;
318        let k = 100.0;
319        let r = 0.05;
320        let sigma = 0.2;
321        let t = 1.0;
322
323        let c = black_scholes_call(s, k, r, sigma, t);
324        let p = black_scholes_put(s, k, r, sigma, t);
325        let parity = s - k * (-r * t).exp();
326
327        assert!(
328            (c - p - parity).abs() < 1e-10,
329            "Put-call parity violated: C={}, P={}, S-Ke^-rT={}",
330            c,
331            p,
332            parity
333        );
334    }
335
336    #[test]
337    fn test_black_scholes_call_value() {
338        // Known approximate value: S=100, K=100, r=5%, sigma=20%, T=1 => C ≈ 10.45
339        let c = black_scholes_call(100.0, 100.0, 0.05, 0.2, 1.0);
340        assert!(
341            (c - 10.4506).abs() < 0.01,
342            "BS call should be ~10.45, got {}",
343            c
344        );
345    }
346
347    #[test]
348    fn test_black_scholes_at_expiry() {
349        assert!((black_scholes_call(110.0, 100.0, 0.05, 0.2, 0.0) - 10.0).abs() < 1e-10);
350        assert!((black_scholes_call(90.0, 100.0, 0.05, 0.2, 0.0) - 0.0).abs() < 1e-10);
351        assert!((black_scholes_put(90.0, 100.0, 0.05, 0.2, 0.0) - 10.0).abs() < 1e-10);
352    }
353
354    #[test]
355    fn test_implied_volatility() {
356        let sigma = 0.25;
357        let price = black_scholes_call(100.0, 100.0, 0.05, sigma, 1.0);
358        let iv = implied_volatility_call(100.0, 100.0, 0.05, 1.0, price);
359        assert!(
360            (iv - sigma).abs() < 0.001,
361            "implied vol {} should be ~{}",
362            iv,
363            sigma
364        );
365    }
366
367    #[test]
368    fn test_greeks_delta_range() {
369        let g = call_greeks(100.0, 100.0, 0.05, 0.2, 1.0);
370        assert!(g.delta > 0.0 && g.delta < 1.0, "delta should be in (0,1)");
371        assert!(g.gamma > 0.0, "gamma should be positive");
372        assert!(g.vega > 0.0, "vega should be positive");
373    }
374
375    #[test]
376    fn test_gbm_renderer() {
377        let renderer = GBMRenderer::new();
378        let gbm = GeometricBM::new(0.05, 0.2, 100.0, 0.01);
379        let mut rng = Rng::new(42);
380        let path = gbm.path(&mut rng, 50);
381        let glyphs = renderer.render_path(&path);
382        assert_eq!(glyphs.len(), 51);
383    }
384}