mollendorff_forge/real_options/
engine.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct OptionResult {
14 pub name: String,
16 pub option_type: OptionType,
18 pub value: f64,
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub probability_exercise: Option<f64>,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub optimal_trigger: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct OptionsResult {
31 pub name: String,
33 pub underlying: UnderlyingResult,
35 pub traditional_npv: f64,
37 pub options: HashMap<String, OptionResult>,
39 pub total_option_value: f64,
41 pub project_value_with_options: f64,
43 pub decision: String,
45 pub recommendation: String,
47}
48
49#[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 #[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 pub fn to_json(&self) -> Result<String, serde_json::Error> {
71 serde_json::to_string_pretty(self)
72 }
73}
74
75pub struct RealOptionsEngine {
77 config: RealOptionsConfig,
78 traditional_npv: f64,
80}
81
82impl RealOptionsEngine {
83 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 #[must_use]
98 pub const fn with_traditional_npv(mut self, npv: f64) -> Self {
99 self.traditional_npv = npv;
100 self
101 }
102
103 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 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, optimal_trigger: trigger,
160 }
161 }
162
163 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(), 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 self.value_with_binomial(option)
217 },
218 }
219 }
220
221 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, 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 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 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 fn value_with_monte_carlo(&self, option: &OptionDefinition) -> f64 {
263 let mut config = self.config.clone();
267 config.binomial_steps = 200; let engine = Self::new(config).unwrap();
269
270 engine.value_with_binomial(option)
272 }
273
274 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; Some(format!("value > ${trigger_value:.0}"))
282 },
283 OptionType::Expand => {
284 let trigger_value = option.exercise_cost * 2.0; 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 fn generate_recommendation(
301 &self,
302 options: &HashMap<String, OptionResult>,
303 total_value: f64,
304 ) -> String {
305 let mut parts = Vec::new();
306
307 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 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 #[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 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 assert!(result.total_option_value > 0.0);
383
384 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 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 assert!(
422 result.total_option_value > 1_000_000.0,
423 "Options should add significant value: {}",
424 result.total_option_value
425 );
426
427 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 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}