surface_lib/models/linear_iv/
interp.rs

1use anyhow::{anyhow, Result};
2use roots::find_root_brent;
3use statrs::distribution::{ContinuousCDF, Normal};
4
5use super::types::*;
6
7/// Compute sorted (log-moneyness, total_variance) points from market data
8/// Filters out invalid IVs (market_iv <= 0), handles duplicates by averaging, and sorts by log-moneyness
9pub fn prepare_points(points: &[MarketDataRow], forward: f64, tte: f64) -> Vec<(f64, f64)> {
10    use std::collections::HashMap;
11
12    let mut x_to_omegas: HashMap<String, Vec<f64>> = HashMap::new();
13
14    for point in points {
15        if point.market_iv <= 0.0 {
16            continue; // Skip invalid IVs
17        }
18
19        let x = (point.strike_price / forward).ln(); // log-moneyness
20        let omega = point.market_iv * point.market_iv * tte; // total variance
21
22        // Use a string key with limited precision to group similar x values
23        let x_key = format!("{:.8}", x); // 8 decimal places precision
24        x_to_omegas.entry(x_key).or_default().push(omega);
25    }
26
27    // Average duplicates and collect results
28    let mut result: Vec<(f64, f64)> = x_to_omegas
29        .into_iter()
30        .map(|(x_str, omegas)| {
31            let x: f64 = x_str.parse().unwrap();
32            let avg_omega = omegas.iter().sum::<f64>() / omegas.len() as f64;
33            (x, avg_omega)
34        })
35        .collect();
36
37    // Sort by log-moneyness for efficient interpolation
38    result.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
39
40    result
41}
42
43/// Linear interpolation in variance space
44/// Returns None if query_x is outside the range and extrapolation is disabled or fails
45pub fn linear_interp(sorted_points: &[(f64, f64)], query_x: f64) -> Option<f64> {
46    linear_interp_with_config(sorted_points, query_x, true)
47}
48
49/// Linear interpolation with configurable extrapolation
50/// Returns None if query_x is outside the range and extrapolation is disabled or fails
51pub fn linear_interp_with_config(
52    sorted_points: &[(f64, f64)],
53    query_x: f64,
54    allow_extrapolation: bool,
55) -> Option<f64> {
56    if sorted_points.is_empty() {
57        return None;
58    }
59
60    if sorted_points.len() == 1 {
61        return Some(sorted_points[0].1);
62    }
63
64    let first_x = sorted_points[0].0;
65    let last_x = sorted_points[sorted_points.len() - 1].0;
66
67    // Handle extrapolation based on configuration
68    if query_x < first_x {
69        if !allow_extrapolation {
70            return None; // Extrapolation disabled
71        }
72        // Extrapolate using first two points
73        if sorted_points.len() < 2 {
74            return Some(sorted_points[0].1);
75        }
76        let (x1, y1) = sorted_points[0];
77        let (x2, y2) = sorted_points[1];
78        let slope = (y2 - y1) / (x2 - x1);
79        let extrapolated = y1 + slope * (query_x - x1);
80        return if extrapolated > 0.0 {
81            Some(extrapolated)
82        } else {
83            None
84        };
85    }
86
87    if query_x > last_x {
88        if !allow_extrapolation {
89            return None; // Extrapolation disabled
90        }
91        // Extrapolate using last two points
92        let n = sorted_points.len();
93        let (x1, y1) = sorted_points[n - 2];
94        let (x2, y2) = sorted_points[n - 1];
95        let slope = (y2 - y1) / (x2 - x1);
96        let extrapolated = y2 + slope * (query_x - x2);
97        return if extrapolated > 0.0 {
98            Some(extrapolated)
99        } else {
100            None
101        };
102    }
103
104    // Find the interval containing query_x
105    for i in 0..sorted_points.len() - 1 {
106        let (x1, y1) = sorted_points[i];
107        let (x2, y2) = sorted_points[i + 1];
108
109        if query_x >= x1 && query_x <= x2 {
110            // Linear interpolation
111            let t = (query_x - x1) / (x2 - x1);
112            let interpolated = y1 + t * (y2 - y1);
113            return Some(interpolated);
114        }
115    }
116
117    None
118}
119
120/// Compute ATM implied volatility via linear interpolation at x=0
121pub fn compute_atm_iv(points: &[MarketDataRow], forward: f64, tte: f64) -> Result<f64> {
122    if tte <= 0.0 {
123        return Err(anyhow!("Time to expiration must be positive, got: {}", tte));
124    }
125
126    let sorted_points = prepare_points(points, forward, tte);
127
128    if sorted_points.len() < 2 {
129        return Err(anyhow!("Insufficient points for ATM IV interpolation"));
130    }
131
132    let omega_atm = linear_interp(&sorted_points, 0.0)
133        .ok_or_else(|| anyhow!("Failed to interpolate ATM variance"))?;
134
135    if omega_atm <= 0.0 {
136        return Err(anyhow!("Non-positive ATM variance: {}", omega_atm));
137    }
138
139    Ok((omega_atm / tte).sqrt())
140}
141
142/// Black-Scholes delta calculation with dividend yield
143/// Uses the standard normal CDF from statrs for precision
144/// x = ln(K/F), so d1 uses -x for standard Black-Scholes formula
145pub fn bs_delta(x: f64, sigma: f64, tte: f64, is_call: bool, q: f64) -> f64 {
146    if sigma <= 0.0 || tte <= 0.0 {
147        return if is_call { 0.0 } else { -1.0 };
148    }
149
150    // Standard Black-Scholes d1: (-x) because x = ln(K/F) and we need ln(F/K)
151    let d1 = -x / (sigma * tte.sqrt()) + 0.5 * sigma * tte.sqrt();
152    let normal = Normal::new(0.0, 1.0).unwrap();
153
154    // Apply dividend yield factor e^(-q*T)
155    let fwd_factor = (-q * tte).exp();
156
157    if is_call {
158        normal.cdf(d1) * fwd_factor
159    } else {
160        (normal.cdf(d1) - 1.0) * fwd_factor
161    }
162}
163
164/// Solve for the log-moneyness that gives the target delta
165/// Uses Brent's method for robust convergence
166pub fn compute_fixed_delta_iv(
167    target_delta: f64,
168    sorted_points: &[(f64, f64)],
169    tte: f64,
170    tol: f64,
171) -> Result<f64> {
172    compute_fixed_delta_iv_with_config(target_delta, sorted_points, tte, tol, true, 0.0)
173}
174
175/// Solve for the log-moneyness that gives the target delta with configurable extrapolation
176/// Uses Brent's method for robust convergence
177pub fn compute_fixed_delta_iv_with_config(
178    target_delta: f64,
179    sorted_points: &[(f64, f64)],
180    tte: f64,
181    tol: f64,
182    allow_extrapolation: bool,
183    q: f64,
184) -> Result<f64> {
185    if sorted_points.is_empty() {
186        return Err(anyhow!("No points available for delta solving"));
187    }
188
189    let is_call = target_delta > 0.0;
190
191    // Define the objective function: bs_delta(x, sigma(x), tte, is_call, q) - target_delta
192    let objective = |x: f64| -> f64 {
193        let omega = match linear_interp_with_config(sorted_points, x, allow_extrapolation) {
194            Some(w) if w > 0.0 => w,
195            _ => {
196                // More neutral fallback values to avoid biasing the solver
197                // Return a large error to push solver away from this region
198                return if is_call {
199                    if target_delta > 0.0 {
200                        -10.0
201                    } else {
202                        10.0
203                    }
204                } else if target_delta < 0.0 {
205                    10.0
206                } else {
207                    -10.0
208                };
209            }
210        };
211
212        let sigma = (omega / tte).sqrt();
213        bs_delta(x, sigma, tte, is_call, q) - target_delta
214    };
215
216    // Determine search bounds based on sorted points
217    let min_x = sorted_points[0].0;
218    let max_x = sorted_points[sorted_points.len() - 1].0;
219
220    // Expand search range for delta solving
221    let search_min = min_x - 1.0;
222    let search_max = max_x + 1.0;
223
224    // Use Brent's method to find the root
225    match find_root_brent(search_min, search_max, &objective, &mut tol.clone()) {
226        Ok(x_solution) => {
227            // Convert back to implied volatility
228            let omega =
229                linear_interp_with_config(sorted_points, x_solution, allow_extrapolation)
230                    .ok_or_else(|| anyhow!("Failed to interpolate at solution x={}", x_solution))?;
231
232            if omega <= 0.0 {
233                return Err(anyhow!("Non-positive variance at solution: {}", omega));
234            }
235
236            Ok((omega / tte).sqrt())
237        }
238        Err(_) => Err(anyhow!(
239            "Root finding failed for target_delta={}",
240            target_delta
241        )),
242    }
243}
244
245/// Compute risk reversal and butterfly metrics for all symmetric delta pairs
246pub fn compute_all_metrics(
247    delta_ivs: &[DeltaIv],
248    atm_iv: f64,
249) -> (Vec<DeltaMetrics>, Option<f64>, Option<f64>) {
250    let mut metrics = Vec::new();
251    let mut rr_25 = None;
252    let mut bf_25 = None;
253
254    // Find all positive deltas and check for their negative counterparts
255    let positive_deltas: Vec<f64> = delta_ivs
256        .iter()
257        .filter_map(|div| {
258            if div.delta > 0.0 {
259                Some(div.delta)
260            } else {
261                None
262            }
263        })
264        .collect();
265
266    for &pos_delta in &positive_deltas {
267        let neg_delta = -pos_delta;
268
269        let call_iv = delta_ivs
270            .iter()
271            .find(|div| (div.delta - pos_delta).abs() < 1e-10)
272            .map(|div| div.iv);
273
274        let put_iv = delta_ivs
275            .iter()
276            .find(|div| (div.delta - neg_delta).abs() < 1e-10)
277            .map(|div| div.iv);
278
279        if let (Some(call_vol), Some(put_vol)) = (call_iv, put_iv) {
280            let rr = call_vol - put_vol;
281            let bf = (call_vol + put_vol) / 2.0 - atm_iv;
282
283            metrics.push(DeltaMetrics {
284                delta_level: pos_delta,
285                risk_reversal: rr,
286                butterfly: bf,
287            });
288
289            // Store 25-delta metrics for backward compatibility
290            if (pos_delta - 0.25).abs() < 1e-10 {
291                rr_25 = Some(rr);
292                bf_25 = Some(bf);
293            }
294        }
295    }
296
297    (metrics, rr_25, bf_25)
298}
299
300/// Compute risk reversal and butterfly metrics from delta IVs (backward compatibility)
301pub fn compute_metrics(delta_ivs: &[DeltaIv], atm_iv: f64) -> (Option<f64>, Option<f64>) {
302    let (_, rr_25, bf_25) = compute_all_metrics(delta_ivs, atm_iv);
303    (rr_25, bf_25)
304}
305
306/// Check if points have reasonable coverage for delta calculations
307/// Logs warnings if asymmetric coverage might affect certain deltas
308fn check_point_coverage(points: &[MarketDataRow], config: &LinearIvConfig) {
309    let has_calls = points.iter().any(|p| p.option_type == "call");
310    let has_puts = points.iter().any(|p| p.option_type == "put");
311
312    let has_negative_deltas = config.deltas.iter().any(|&d| d < 0.0);
313    let has_positive_deltas = config.deltas.iter().any(|&d| d > 0.0);
314
315    if has_negative_deltas && !has_puts {
316        eprintln!("Warning: Negative deltas requested but no put options in data. Some delta calculations may fail.");
317    }
318
319    if has_positive_deltas && !has_calls {
320        eprintln!("Warning: Positive deltas requested but no call options in data. Some delta calculations may fail.");
321    }
322
323    if !has_calls && !has_puts {
324        eprintln!("Warning: No clear option type classification in data.");
325    }
326}
327
328/// Convenience function to build linear IV output using underlying price from market data
329/// Uses the underlying_price from the first market data point as the forward price
330pub fn build_linear_iv_from_market_data(
331    points: &[MarketDataRow],
332    config: &LinearIvConfig,
333) -> Result<LinearIvOutput> {
334    if points.is_empty() {
335        return Err(anyhow!("No market data provided"));
336    }
337
338    let forward = points[0].underlying_price;
339    let tte = points[0].years_to_exp;
340
341    build_linear_iv(points, forward, tte, config)
342}
343
344/// Main function to build complete linear IV output
345/// Orchestrates all the above functions to produce the final result
346pub fn build_linear_iv(
347    points: &[MarketDataRow],
348    forward: f64,
349    tte: f64,
350    config: &LinearIvConfig,
351) -> Result<LinearIvOutput> {
352    if points.len() < config.min_points {
353        return Err(anyhow!(
354            "Insufficient points: {} < {}",
355            points.len(),
356            config.min_points
357        ));
358    }
359
360    // Check for potential issues with point coverage
361    check_point_coverage(points, config);
362
363    // Compute ATM IV
364    let atm_iv = compute_atm_iv(points, forward, tte)?;
365
366    // Prepare sorted points for delta solving
367    let sorted_points = prepare_points(points, forward, tte);
368
369    // Compute fixed-delta IVs
370    let mut delta_ivs = Vec::new();
371
372    for &delta in &config.deltas {
373        match compute_fixed_delta_iv_with_config(
374            delta,
375            &sorted_points,
376            tte,
377            config.solver_tol,
378            config.allow_extrapolation,
379            config.dividend_yield,
380        ) {
381            Ok(iv) => {
382                delta_ivs.push(DeltaIv { delta, iv });
383            }
384            Err(_) => {
385                // Skip deltas that fail to solve (e.g., too far OTM)
386                continue;
387            }
388        }
389    }
390
391    // Compute all metrics (including backward-compatible 25-delta)
392    let (delta_metrics, rr_25, bf_25) = compute_all_metrics(&delta_ivs, atm_iv);
393
394    Ok(LinearIvOutput {
395        atm_iv,
396        delta_ivs,
397        rr_25,
398        bf_25,
399        delta_metrics,
400        tte,
401    })
402}