Skip to main content

mollendorff_forge/real_options/
engine.rs

1//! Real Options Engine
2//!
3//! Orchestrates option valuation using configured method.
4
5use super::binomial::BinomialTree;
6use super::black_scholes::BlackScholes;
7use super::config::{OptionDefinition, OptionType, RealOptionsConfig, ValuationMethod};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Result for a single option
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct OptionResult {
14    /// Option name
15    pub name: String,
16    /// Option type
17    pub option_type: OptionType,
18    /// Option value
19    pub value: f64,
20    /// Probability of exercise (from simulation)
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub probability_exercise: Option<f64>,
23    /// Optimal trigger condition
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub optimal_trigger: Option<String>,
26}
27
28/// Complete options analysis result
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct OptionsResult {
31    /// Analysis name
32    pub name: String,
33    /// Underlying asset parameters
34    pub underlying: UnderlyingResult,
35    /// Traditional NPV (without options)
36    pub traditional_npv: f64,
37    /// Individual option results
38    pub options: HashMap<String, OptionResult>,
39    /// Total option value
40    pub total_option_value: f64,
41    /// Project value with options (NPV + option value)
42    pub project_value_with_options: f64,
43    /// Decision recommendation
44    pub decision: String,
45    /// Detailed recommendation
46    pub recommendation: String,
47}
48
49/// Underlying asset summary
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct UnderlyingResult {
52    pub current_value: f64,
53    pub volatility: f64,
54    pub risk_free_rate: f64,
55    pub time_horizon: f64,
56}
57
58impl OptionsResult {
59    /// Export results to YAML format
60    #[must_use]
61    pub fn to_yaml(&self) -> String {
62        serde_yaml_ng::to_string(self).unwrap_or_else(|_| "# Error serializing results".to_string())
63    }
64
65    /// Export results to JSON format
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if JSON serialization fails.
70    pub fn to_json(&self) -> Result<String, serde_json::Error> {
71        serde_json::to_string_pretty(self)
72    }
73}
74
75/// Real Options Engine
76pub struct RealOptionsEngine {
77    config: RealOptionsConfig,
78    /// Traditional NPV (can be set externally)
79    traditional_npv: f64,
80}
81
82impl RealOptionsEngine {
83    /// Create a new real options engine
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the configuration is invalid.
88    pub fn new(config: RealOptionsConfig) -> Result<Self, String> {
89        config.validate()?;
90        Ok(Self {
91            config,
92            traditional_npv: 0.0,
93        })
94    }
95
96    /// Set the traditional NPV for comparison
97    #[must_use]
98    pub const fn with_traditional_npv(mut self, npv: f64) -> Self {
99        self.traditional_npv = npv;
100        self
101    }
102
103    /// Analyze all options
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if any option valuation fails.
108    pub fn analyze(&self) -> Result<OptionsResult, String> {
109        let mut option_results = HashMap::new();
110        let mut total_value = 0.0;
111
112        for option in &self.config.options {
113            let result = self.value_option(option);
114            total_value += result.value;
115            option_results.insert(option.name.clone(), result);
116        }
117
118        let project_value = self.traditional_npv + total_value;
119        let decision = if project_value > 0.0 {
120            "ACCEPT (with options)".to_string()
121        } else {
122            "REJECT".to_string()
123        };
124
125        let recommendation = self.generate_recommendation(&option_results, total_value);
126
127        Ok(OptionsResult {
128            name: self.config.name.clone(),
129            underlying: UnderlyingResult {
130                current_value: self.config.underlying.current_value,
131                volatility: self.config.underlying.volatility,
132                risk_free_rate: self.config.underlying.risk_free_rate,
133                time_horizon: self.config.underlying.time_horizon,
134            },
135            traditional_npv: self.traditional_npv,
136            options: option_results,
137            total_option_value: total_value,
138            project_value_with_options: project_value,
139            decision,
140            recommendation,
141        })
142    }
143
144    /// Value a single option
145    fn value_option(&self, option: &OptionDefinition) -> OptionResult {
146        let value = match self.config.method {
147            ValuationMethod::BlackScholes => self.value_with_black_scholes(option),
148            ValuationMethod::Binomial => self.value_with_binomial(option),
149            ValuationMethod::MonteCarlo => self.value_with_monte_carlo(option),
150        };
151
152        let trigger = self.determine_trigger(option);
153
154        OptionResult {
155            name: option.name.clone(),
156            option_type: option.option_type,
157            value,
158            probability_exercise: None, // Would be populated from simulation
159            optimal_trigger: trigger,
160        }
161    }
162
163    /// Value option using Black-Scholes
164    fn value_with_black_scholes(&self, option: &OptionDefinition) -> f64 {
165        let u = &self.config.underlying;
166
167        match option.option_type {
168            OptionType::Defer => {
169                let bs = BlackScholes::new(
170                    u.current_value,
171                    option.exercise_cost,
172                    u.risk_free_rate,
173                    u.volatility,
174                    option.max_deferral.min(u.time_horizon),
175                )
176                .with_dividend_yield(u.dividend_yield);
177                bs.call_price()
178            },
179            OptionType::Expand => {
180                let additional_value = (option.expansion_factor - 1.0) * u.current_value;
181                let bs = BlackScholes::new(
182                    additional_value,
183                    option.exercise_cost,
184                    u.risk_free_rate,
185                    u.volatility,
186                    u.time_horizon,
187                )
188                .with_dividend_yield(u.dividend_yield);
189                bs.call_price()
190            },
191            OptionType::Abandon => {
192                let bs = BlackScholes::new(
193                    u.current_value,
194                    option.salvage_value,
195                    u.risk_free_rate,
196                    u.volatility,
197                    u.time_horizon,
198                )
199                .with_dividend_yield(u.dividend_yield);
200                bs.put_price()
201            },
202            OptionType::Contract => {
203                let reduction = (1.0 - option.contraction_factor) * u.current_value;
204                let bs = BlackScholes::new(
205                    reduction,
206                    option.exercise_cost.abs(), // Cost savings
207                    u.risk_free_rate,
208                    u.volatility,
209                    u.time_horizon,
210                )
211                .with_dividend_yield(u.dividend_yield);
212                bs.put_price()
213            },
214            OptionType::Switch | OptionType::Compound => {
215                // Complex options - fallback to binomial
216                self.value_with_binomial(option)
217            },
218        }
219    }
220
221    /// Value option using binomial tree
222    fn value_with_binomial(&self, option: &OptionDefinition) -> f64 {
223        let u = &self.config.underlying;
224        let steps = self.config.binomial_steps;
225
226        let tree = BinomialTree::new(
227            u.current_value,
228            u.current_value, // Placeholder, actual strike varies by option type
229            u.risk_free_rate,
230            u.volatility,
231            u.time_horizon,
232            steps,
233        )
234        .with_dividend_yield(u.dividend_yield);
235
236        match option.option_type {
237            OptionType::Defer => tree.defer_option_value(option.max_deferral, option.exercise_cost),
238            OptionType::Expand => {
239                tree.expand_option_value(option.expansion_factor, option.exercise_cost)
240            },
241            OptionType::Abandon => tree.abandon_option_value(option.salvage_value),
242            OptionType::Contract => {
243                tree.contract_option_value(option.contraction_factor, option.exercise_cost.abs())
244            },
245            OptionType::Switch => {
246                // Switch option approximated as max of expand and contract
247                let expand = tree.expand_option_value(1.2, option.exercise_cost);
248                let contract = tree.contract_option_value(0.8, option.exercise_cost.abs());
249                expand.max(contract)
250            },
251            OptionType::Compound => {
252                // Compound option - simplified as defer then expand
253                let defer = tree.defer_option_value(1.0, option.exercise_cost * 0.5);
254                let expand =
255                    tree.expand_option_value(option.expansion_factor, option.exercise_cost);
256                expand.mul_add(0.5, defer)
257            },
258        }
259    }
260
261    /// Value option using Monte Carlo simulation
262    fn value_with_monte_carlo(&self, option: &OptionDefinition) -> f64 {
263        // Simplified LSM (Longstaff-Schwartz) approximation
264        // For full implementation, would use path simulation
265        // Falls back to binomial for now with more steps
266        let mut config = self.config.clone();
267        config.binomial_steps = 200; // More accurate
268        let engine = Self::new(config).unwrap();
269
270        // Use binomial with more steps as approximation
271        engine.value_with_binomial(option)
272    }
273
274    /// Determine optimal trigger condition
275    fn determine_trigger(&self, option: &OptionDefinition) -> Option<String> {
276        let u = &self.config.underlying;
277
278        match option.option_type {
279            OptionType::Defer => {
280                let trigger_value = option.exercise_cost * 1.1; // 10% above exercise cost
281                Some(format!("value > ${trigger_value:.0}"))
282            },
283            OptionType::Expand => {
284                let trigger_value = option.exercise_cost * 2.0; // Good ROI on expansion
285                Some(format!("value > ${trigger_value:.0}"))
286            },
287            OptionType::Abandon => {
288                let trigger_value = option.salvage_value * 1.2;
289                Some(format!("value < ${trigger_value:.0}"))
290            },
291            OptionType::Contract => {
292                let trigger_value = u.current_value * option.contraction_factor;
293                Some(format!("value < ${trigger_value:.0}"))
294            },
295            _ => None,
296        }
297    }
298
299    /// Generate recommendation text
300    fn generate_recommendation(
301        &self,
302        options: &HashMap<String, OptionResult>,
303        total_value: f64,
304    ) -> String {
305        let mut parts = Vec::new();
306
307        // Find highest value option
308        if let Some((name, result)) = options.iter().max_by(|a, b| {
309            a.1.value
310                .partial_cmp(&b.1.value)
311                .unwrap_or(std::cmp::Ordering::Equal)
312        }) {
313            parts.push(format!(
314                "Highest value option: {} (${:.0})",
315                name, result.value
316            ));
317        }
318
319        if total_value > self.traditional_npv.abs() {
320            parts.push("Option value exceeds negative NPV - consider proceeding".to_string());
321        }
322
323        // Add specific recommendations by option type
324        for (name, result) in options {
325            if let Some(ref trigger) = result.optimal_trigger {
326                parts.push(format!("{name}: exercise when {trigger}"));
327            }
328        }
329
330        parts.join(". ")
331    }
332
333    /// Get the configuration
334    #[must_use]
335    pub const fn config(&self) -> &RealOptionsConfig {
336        &self.config
337    }
338}
339
340#[cfg(test)]
341mod engine_tests {
342    use super::*;
343    use crate::real_options::config::{OptionDefinition, UnderlyingConfig};
344
345    fn create_test_config() -> RealOptionsConfig {
346        RealOptionsConfig::new(
347            "Factory Investment",
348            UnderlyingConfig::new(10_000_000.0, 0.30, 0.05, 3.0),
349        )
350        .with_option(OptionDefinition::defer("Wait 2 years", 2.0, 8_000_000.0))
351        .with_option(OptionDefinition::expand("Build Phase 2", 1.5, 4_000_000.0))
352        .with_option(OptionDefinition::abandon("Sell assets", 3_000_000.0))
353        .with_binomial_steps(100)
354    }
355
356    #[test]
357    fn test_engine_creation() {
358        let config = create_test_config();
359        let engine = RealOptionsEngine::new(config);
360        assert!(engine.is_ok());
361    }
362
363    #[test]
364    fn test_option_valuation() {
365        let config = create_test_config();
366        let engine = RealOptionsEngine::new(config)
367            .unwrap()
368            .with_traditional_npv(-500_000.0);
369
370        let result = engine.analyze().unwrap();
371
372        // All options should have positive value
373        for opt_result in result.options.values() {
374            assert!(
375                opt_result.value > 0.0,
376                "{} should have positive value",
377                opt_result.name
378            );
379        }
380
381        // Total option value should be positive
382        assert!(result.total_option_value > 0.0);
383
384        // With options, might turn negative NPV positive
385        println!("Traditional NPV: ${}", result.traditional_npv);
386        println!("Total Option Value: ${}", result.total_option_value);
387        println!(
388            "Project Value with Options: ${}",
389            result.project_value_with_options
390        );
391    }
392
393    #[test]
394    fn test_adr020_example() {
395        // From ADR-020:
396        // Traditional NPV: -$500K
397        // With options: +$1.9M
398        // Options add $2.4M of value
399
400        let config = RealOptionsConfig::new(
401            "Phased Factory Investment",
402            UnderlyingConfig::new(10_000_000.0, 0.30, 0.05, 3.0),
403        )
404        .with_method(ValuationMethod::Binomial)
405        .with_option(OptionDefinition::defer(
406            "Wait up to 2 years",
407            2.0,
408            8_000_000.0,
409        ))
410        .with_option(OptionDefinition::expand("Build Phase 2", 1.5, 4_000_000.0))
411        .with_option(OptionDefinition::abandon("Sell assets", 3_000_000.0))
412        .with_binomial_steps(100);
413
414        let engine = RealOptionsEngine::new(config)
415            .unwrap()
416            .with_traditional_npv(-500_000.0);
417
418        let result = engine.analyze().unwrap();
419
420        // Total option value should be substantial
421        assert!(
422            result.total_option_value > 1_000_000.0,
423            "Options should add significant value: {}",
424            result.total_option_value
425        );
426
427        // Project should be acceptable with options
428        if result.project_value_with_options > 0.0 {
429            assert_eq!(result.decision, "ACCEPT (with options)");
430        }
431    }
432
433    #[test]
434    fn test_black_scholes_method() {
435        let config =
436            RealOptionsConfig::new("BS Test", UnderlyingConfig::new(100.0, 0.20, 0.05, 1.0))
437                .with_method(ValuationMethod::BlackScholes)
438                .with_option(OptionDefinition::defer("Wait 1 year", 1.0, 100.0));
439
440        let engine = RealOptionsEngine::new(config).unwrap();
441        let result = engine.analyze().unwrap();
442
443        // Defer option value should be close to BS call value
444        let defer_value = result.options.get("Wait 1 year").unwrap().value;
445        assert!(defer_value > 5.0 && defer_value < 20.0);
446    }
447
448    #[test]
449    fn test_yaml_export() {
450        let config = create_test_config();
451        let engine = RealOptionsEngine::new(config).unwrap();
452        let result = engine.analyze().unwrap();
453        let yaml = result.to_yaml();
454
455        assert!(yaml.contains("total_option_value"));
456        assert!(yaml.contains("project_value_with_options"));
457        assert!(yaml.contains("decision"));
458    }
459
460    #[test]
461    fn test_json_export() {
462        let config = create_test_config();
463        let engine = RealOptionsEngine::new(config).unwrap();
464        let result = engine.analyze().unwrap();
465        let json = result.to_json().unwrap();
466
467        assert!(json.contains("\"total_option_value\""));
468        assert!(json.contains("\"options\""));
469    }
470}