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 approximation 26.2.17
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    let sign = if x < 0.0 { -1.0 } else { 1.0 };
84    let x_abs = x.abs();
85    let t = 1.0 / (1.0 + p * x_abs);
86    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x_abs * x_abs / 2.0).exp();
87
88    0.5 * (1.0 + sign * y)
89}
90
91/// Black-Scholes European call option price.
92///
93/// * `s` - Current stock price
94/// * `k` - Strike price
95/// * `r` - Risk-free interest rate (annualised)
96/// * `sigma` - Volatility (annualised)
97/// * `t` - Time to expiration (years)
98pub fn black_scholes_call(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> f64 {
99    if t <= 0.0 {
100        return (s - k).max(0.0);
101    }
102    let d1 = ((s / k).ln() + (r + 0.5 * sigma * sigma) * t) / (sigma * t.sqrt());
103    let d2 = d1 - sigma * t.sqrt();
104    s * normal_cdf(d1) - k * (-r * t).exp() * normal_cdf(d2)
105}
106
107/// Black-Scholes European put option price.
108///
109/// * `s` - Current stock price
110/// * `k` - Strike price
111/// * `r` - Risk-free interest rate (annualised)
112/// * `sigma` - Volatility (annualised)
113/// * `t` - Time to expiration (years)
114pub fn black_scholes_put(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> f64 {
115    if t <= 0.0 {
116        return (k - s).max(0.0);
117    }
118    let d1 = ((s / k).ln() + (r + 0.5 * sigma * sigma) * t) / (sigma * t.sqrt());
119    let d2 = d1 - sigma * t.sqrt();
120    k * (-r * t).exp() * normal_cdf(-d2) - s * normal_cdf(-d1)
121}
122
123/// Compute the implied volatility for a call option using bisection.
124pub fn implied_volatility_call(s: f64, k: f64, r: f64, t: f64, market_price: f64) -> f64 {
125    let mut lo = 0.001;
126    let mut hi = 5.0;
127    for _ in 0..100 {
128        let mid = (lo + hi) / 2.0;
129        let price = black_scholes_call(s, k, r, mid, t);
130        if price < market_price {
131            lo = mid;
132        } else {
133            hi = mid;
134        }
135    }
136    (lo + hi) / 2.0
137}
138
139/// Greeks for a European call option.
140pub struct Greeks {
141    pub delta: f64,
142    pub gamma: f64,
143    pub theta: f64,
144    pub vega: f64,
145    pub rho: f64,
146}
147
148/// Compute Greeks for a European call.
149pub fn call_greeks(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> Greeks {
150    let sqrt_t = t.sqrt();
151    let d1 = ((s / k).ln() + (r + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
152    let d2 = d1 - sigma * sqrt_t;
153    let pdf_d1 = (-0.5 * d1 * d1).exp() / (2.0 * std::f64::consts::PI).sqrt();
154
155    let delta = normal_cdf(d1);
156    let gamma = pdf_d1 / (s * sigma * sqrt_t);
157    let theta = -(s * pdf_d1 * sigma) / (2.0 * sqrt_t) - r * k * (-r * t).exp() * normal_cdf(d2);
158    let vega = s * pdf_d1 * sqrt_t;
159    let rho = k * t * (-r * t).exp() * normal_cdf(d2);
160
161    Greeks { delta, gamma, theta, vega, rho }
162}
163
164// ---------------------------------------------------------------------------
165// GBMRenderer
166// ---------------------------------------------------------------------------
167
168/// Render GBM price paths as glyph line charts.
169pub struct GBMRenderer {
170    pub character: char,
171    pub color: [f32; 4],
172    pub x_scale: f32,
173    pub y_scale: f32,
174}
175
176impl GBMRenderer {
177    pub fn new() -> Self {
178        Self {
179            character: '█',
180            color: [0.2, 1.0, 0.3, 1.0],
181            x_scale: 0.05,
182            y_scale: 0.01,
183        }
184    }
185
186    pub fn with_scales(mut self, x_scale: f32, y_scale: f32) -> Self {
187        self.x_scale = x_scale;
188        self.y_scale = y_scale;
189        self
190    }
191
192    /// Render a single price path as positioned glyphs.
193    pub fn render_path(&self, path: &[f64]) -> Vec<(Vec2, char, [f32; 4])> {
194        path.iter()
195            .enumerate()
196            .map(|(i, &price)| {
197                let pos = Vec2::new(i as f32 * self.x_scale, price as f32 * self.y_scale);
198                (pos, self.character, self.color)
199            })
200            .collect()
201    }
202
203    /// Render multiple paths with varying alpha for a fan chart effect.
204    pub fn render_fan(&self, paths: &[Vec<f64>]) -> Vec<(Vec2, char, [f32; 4])> {
205        let n = paths.len().max(1);
206        let mut glyphs = Vec::new();
207        for (pi, path) in paths.iter().enumerate() {
208            let alpha = 0.1 + 0.3 * (pi as f32 / n as f32);
209            let color = [self.color[0], self.color[1], self.color[2], alpha];
210            for (i, &price) in path.iter().enumerate() {
211                let pos = Vec2::new(i as f32 * self.x_scale, price as f32 * self.y_scale);
212                glyphs.push((pos, self.character, color));
213            }
214        }
215        glyphs
216    }
217}
218
219impl Default for GBMRenderer {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225// ---------------------------------------------------------------------------
226// Tests
227// ---------------------------------------------------------------------------
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_gbm_always_positive() {
235        let gbm = GeometricBM::new(0.05, 0.2, 100.0, 0.01);
236        let mut rng = Rng::new(42);
237        let path = gbm.path(&mut rng, 1000);
238        assert!(path.iter().all(|&p| p > 0.0), "GBM should always be positive");
239    }
240
241    #[test]
242    fn test_gbm_expected_value() {
243        // E[S(t)] = S0 * exp(mu*t)
244        let mu = 0.05;
245        let sigma = 0.3;
246        let s0 = 100.0;
247        let dt = 0.001;
248        let steps = 1000; // t = 1.0
249        let trials = 5000;
250        let gbm = GeometricBM::new(mu, sigma, s0, dt);
251        let mut rng = Rng::new(12345);
252
253        let sum: f64 = (0..trials)
254            .map(|_| {
255                let path = gbm.path(&mut rng, steps);
256                *path.last().unwrap()
257            })
258            .sum();
259        let empirical_mean = sum / trials as f64;
260        let expected = s0 * (mu * 1.0).exp(); // ~105.13
261
262        assert!(
263            (empirical_mean - expected).abs() / expected < 0.1,
264            "empirical mean {} should be close to expected {}",
265            empirical_mean,
266            expected
267        );
268    }
269
270    #[test]
271    fn test_gbm_log_normal() {
272        // ln(S(t)/S(0)) should be normally distributed with
273        // mean (mu - sigma²/2)*t and variance sigma²*t
274        let mu = 0.1;
275        let sigma = 0.2;
276        let s0 = 100.0;
277        let dt = 0.01;
278        let steps = 100; // t = 1.0
279        let trials = 10_000;
280        let gbm = GeometricBM::new(mu, sigma, s0, dt);
281        let mut rng = Rng::new(777);
282
283        let log_returns: Vec<f64> = (0..trials)
284            .map(|_| {
285                let path = gbm.path(&mut rng, steps);
286                (path.last().unwrap() / s0).ln()
287            })
288            .collect();
289
290        let mean = log_returns.iter().sum::<f64>() / trials as f64;
291        let var = log_returns.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / trials as f64;
292        let expected_mean = (mu - 0.5 * sigma * sigma) * 1.0;
293        let expected_var = sigma * sigma * 1.0;
294
295        assert!(
296            (mean - expected_mean).abs() < 0.05,
297            "log-return mean {} should be ~{}",
298            mean,
299            expected_mean
300        );
301        assert!(
302            (var - expected_var).abs() < 0.02,
303            "log-return variance {} should be ~{}",
304            var,
305            expected_var
306        );
307    }
308
309    #[test]
310    fn test_black_scholes_put_call_parity() {
311        // C - P = S - K*exp(-rT)
312        let s = 100.0;
313        let k = 100.0;
314        let r = 0.05;
315        let sigma = 0.2;
316        let t = 1.0;
317
318        let c = black_scholes_call(s, k, r, sigma, t);
319        let p = black_scholes_put(s, k, r, sigma, t);
320        let parity = s - k * (-r * t).exp();
321
322        assert!(
323            (c - p - parity).abs() < 1e-10,
324            "Put-call parity violated: C={}, P={}, S-Ke^-rT={}",
325            c,
326            p,
327            parity
328        );
329    }
330
331    #[test]
332    fn test_black_scholes_call_value() {
333        // Known approximate value: S=100, K=100, r=5%, sigma=20%, T=1 => C ≈ 10.45
334        let c = black_scholes_call(100.0, 100.0, 0.05, 0.2, 1.0);
335        assert!(
336            (c - 10.45).abs() < 0.5,
337            "BS call should be ~10.45, got {}",
338            c
339        );
340    }
341
342    #[test]
343    fn test_black_scholes_at_expiry() {
344        assert!((black_scholes_call(110.0, 100.0, 0.05, 0.2, 0.0) - 10.0).abs() < 1e-10);
345        assert!((black_scholes_call(90.0, 100.0, 0.05, 0.2, 0.0) - 0.0).abs() < 1e-10);
346        assert!((black_scholes_put(90.0, 100.0, 0.05, 0.2, 0.0) - 10.0).abs() < 1e-10);
347    }
348
349    #[test]
350    fn test_implied_volatility() {
351        let sigma = 0.25;
352        let price = black_scholes_call(100.0, 100.0, 0.05, sigma, 1.0);
353        let iv = implied_volatility_call(100.0, 100.0, 0.05, 1.0, price);
354        assert!(
355            (iv - sigma).abs() < 0.001,
356            "implied vol {} should be ~{}",
357            iv,
358            sigma
359        );
360    }
361
362    #[test]
363    fn test_greeks_delta_range() {
364        let g = call_greeks(100.0, 100.0, 0.05, 0.2, 1.0);
365        assert!(g.delta > 0.0 && g.delta < 1.0, "delta should be in (0,1)");
366        assert!(g.gamma > 0.0, "gamma should be positive");
367        assert!(g.vega > 0.0, "vega should be positive");
368    }
369
370    #[test]
371    fn test_gbm_renderer() {
372        let renderer = GBMRenderer::new();
373        let gbm = GeometricBM::new(0.05, 0.2, 100.0, 0.01);
374        let mut rng = Rng::new(42);
375        let path = gbm.path(&mut rng, 50);
376        let glyphs = renderer.render_path(&path);
377        assert_eq!(glyphs.len(), 51);
378    }
379}