Skip to main content

mollendorff_forge/real_options/
config.rs

1//! Real Options Configuration
2//!
3//! Handles parsing and validation of real options definitions from YAML.
4
5use serde::{Deserialize, Serialize};
6
7/// Type of real option
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum OptionType {
11    /// Wait before investing
12    Defer,
13    /// Scale up if successful
14    Expand,
15    /// Scale down if weak
16    Contract,
17    /// Exit and recover salvage
18    Abandon,
19    /// Change inputs/outputs
20    Switch,
21    /// Option on an option
22    Compound,
23}
24
25/// Valuation method
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum ValuationMethod {
29    /// Closed-form Black-Scholes (European options)
30    BlackScholes,
31    /// Binomial tree (American options, path-dependent)
32    #[default]
33    Binomial,
34    /// Monte Carlo simulation (complex/exotic options)
35    MonteCarlo,
36}
37
38/// Definition of a single option
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct OptionDefinition {
41    /// Type of option
42    #[serde(rename = "type")]
43    pub option_type: OptionType,
44    /// Human-readable name
45    pub name: String,
46    /// Cost to exercise the option
47    #[serde(default)]
48    pub exercise_cost: f64,
49    /// Salvage value (for abandon options)
50    #[serde(default)]
51    pub salvage_value: f64,
52    /// Maximum deferral period in years (for defer options)
53    #[serde(default)]
54    pub max_deferral: f64,
55    /// Expansion factor (for expand options)
56    #[serde(default = "default_expansion_factor")]
57    pub expansion_factor: f64,
58    /// Contraction factor (for contract options)
59    #[serde(default = "default_contraction_factor")]
60    pub contraction_factor: f64,
61}
62
63const fn default_expansion_factor() -> f64 {
64    1.5
65}
66
67const fn default_contraction_factor() -> f64 {
68    0.5
69}
70
71impl OptionDefinition {
72    /// Create a defer option
73    #[must_use]
74    pub fn defer(name: &str, max_deferral: f64, exercise_cost: f64) -> Self {
75        Self {
76            option_type: OptionType::Defer,
77            name: name.to_string(),
78            exercise_cost,
79            salvage_value: 0.0,
80            max_deferral,
81            expansion_factor: 1.0,
82            contraction_factor: 1.0,
83        }
84    }
85
86    /// Create an expand option
87    #[must_use]
88    pub fn expand(name: &str, expansion_factor: f64, exercise_cost: f64) -> Self {
89        Self {
90            option_type: OptionType::Expand,
91            name: name.to_string(),
92            exercise_cost,
93            salvage_value: 0.0,
94            max_deferral: 0.0,
95            expansion_factor,
96            contraction_factor: 1.0,
97        }
98    }
99
100    /// Create an abandon option
101    #[must_use]
102    pub fn abandon(name: &str, salvage_value: f64) -> Self {
103        Self {
104            option_type: OptionType::Abandon,
105            name: name.to_string(),
106            exercise_cost: 0.0,
107            salvage_value,
108            max_deferral: 0.0,
109            expansion_factor: 1.0,
110            contraction_factor: 1.0,
111        }
112    }
113
114    /// Create a contract option
115    #[must_use]
116    pub fn contract(name: &str, contraction_factor: f64, cost_savings: f64) -> Self {
117        Self {
118            option_type: OptionType::Contract,
119            name: name.to_string(),
120            exercise_cost: -cost_savings, // Negative cost = savings
121            salvage_value: 0.0,
122            max_deferral: 0.0,
123            expansion_factor: 1.0,
124            contraction_factor,
125        }
126    }
127}
128
129/// Underlying asset configuration
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct UnderlyingConfig {
132    /// Present value of project cash flows
133    pub current_value: f64,
134    /// Annual volatility of value
135    pub volatility: f64,
136    /// Annual risk-free rate
137    pub risk_free_rate: f64,
138    /// Time horizon in years
139    pub time_horizon: f64,
140    /// Dividend yield (continuous)
141    #[serde(default)]
142    pub dividend_yield: f64,
143}
144
145impl UnderlyingConfig {
146    /// Create a new underlying configuration
147    #[must_use]
148    pub const fn new(
149        current_value: f64,
150        volatility: f64,
151        risk_free_rate: f64,
152        time_horizon: f64,
153    ) -> Self {
154        Self {
155            current_value,
156            volatility,
157            risk_free_rate,
158            time_horizon,
159            dividend_yield: 0.0,
160        }
161    }
162
163    /// Add dividend yield
164    #[must_use]
165    pub const fn with_dividend_yield(mut self, yield_rate: f64) -> Self {
166        self.dividend_yield = yield_rate;
167        self
168    }
169
170    /// Validate configuration
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the underlying parameters are out of range (e.g.,
175    /// non-positive value, volatility, or time horizon).
176    pub fn validate(&self) -> Result<(), String> {
177        if self.current_value <= 0.0 {
178            return Err("Current value must be positive".to_string());
179        }
180        if self.volatility <= 0.0 || self.volatility > 2.0 {
181            return Err("Volatility must be between 0 and 200%".to_string());
182        }
183        if self.risk_free_rate < 0.0 || self.risk_free_rate > 1.0 {
184            return Err("Risk-free rate must be between 0% and 100%".to_string());
185        }
186        if self.time_horizon <= 0.0 {
187            return Err("Time horizon must be positive".to_string());
188        }
189        Ok(())
190    }
191}
192
193/// Configuration for real options analysis
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct RealOptionsConfig {
196    /// Name of the analysis
197    #[serde(default)]
198    pub name: String,
199    /// Valuation method
200    #[serde(default)]
201    pub method: ValuationMethod,
202    /// Underlying asset configuration
203    pub underlying: UnderlyingConfig,
204    /// Options to value
205    #[serde(default)]
206    pub options: Vec<OptionDefinition>,
207    /// Number of steps in binomial tree
208    #[serde(default = "default_binomial_steps")]
209    pub binomial_steps: usize,
210    /// Number of Monte Carlo iterations
211    #[serde(default = "default_mc_iterations")]
212    pub monte_carlo_iterations: usize,
213    /// Random seed for reproducibility
214    pub seed: Option<u64>,
215}
216
217const fn default_binomial_steps() -> usize {
218    100
219}
220
221const fn default_mc_iterations() -> usize {
222    10000
223}
224
225impl RealOptionsConfig {
226    /// Create a new configuration
227    #[must_use]
228    pub fn new(name: &str, underlying: UnderlyingConfig) -> Self {
229        Self {
230            name: name.to_string(),
231            method: ValuationMethod::Binomial,
232            underlying,
233            options: Vec::new(),
234            binomial_steps: 100,
235            monte_carlo_iterations: 10000,
236            seed: None,
237        }
238    }
239
240    /// Set valuation method
241    #[must_use]
242    pub const fn with_method(mut self, method: ValuationMethod) -> Self {
243        self.method = method;
244        self
245    }
246
247    /// Add an option
248    #[must_use]
249    pub fn with_option(mut self, option: OptionDefinition) -> Self {
250        self.options.push(option);
251        self
252    }
253
254    /// Set binomial steps
255    #[must_use]
256    pub const fn with_binomial_steps(mut self, steps: usize) -> Self {
257        self.binomial_steps = steps;
258        self
259    }
260
261    /// Set seed
262    #[must_use]
263    pub const fn with_seed(mut self, seed: u64) -> Self {
264        self.seed = Some(seed);
265        self
266    }
267
268    /// Validate configuration
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if the underlying is invalid, no options are defined,
273    /// or parameters are zero.
274    pub fn validate(&self) -> Result<(), String> {
275        self.underlying.validate()?;
276
277        if self.options.is_empty() {
278            return Err("At least one option must be defined".to_string());
279        }
280
281        if self.binomial_steps == 0 {
282            return Err("Binomial steps must be positive".to_string());
283        }
284
285        if self.monte_carlo_iterations == 0 {
286            return Err("Monte Carlo iterations must be positive".to_string());
287        }
288
289        Ok(())
290    }
291}
292
293#[cfg(test)]
294// Financial math: exact float comparison validated against Excel/Gnumeric/R
295#[allow(clippy::float_cmp)]
296mod config_tests {
297    use super::*;
298
299    #[test]
300    fn test_underlying_validation() {
301        let underlying = UnderlyingConfig::new(10_000_000.0, 0.30, 0.05, 3.0);
302        assert!(underlying.validate().is_ok());
303
304        let bad_value = UnderlyingConfig::new(-100.0, 0.30, 0.05, 3.0);
305        assert!(bad_value.validate().is_err());
306
307        let bad_vol = UnderlyingConfig::new(100.0, -0.1, 0.05, 3.0);
308        assert!(bad_vol.validate().is_err());
309    }
310
311    #[test]
312    fn test_config_builder() {
313        let config = RealOptionsConfig::new(
314            "Factory Investment",
315            UnderlyingConfig::new(10_000_000.0, 0.30, 0.05, 3.0),
316        )
317        .with_method(ValuationMethod::Binomial)
318        .with_option(OptionDefinition::defer("Wait 2 years", 2.0, 8_000_000.0))
319        .with_option(OptionDefinition::abandon("Sell assets", 3_000_000.0))
320        .with_binomial_steps(50);
321
322        assert!(config.validate().is_ok());
323        assert_eq!(config.options.len(), 2);
324    }
325
326    #[test]
327    fn test_option_types() {
328        let defer = OptionDefinition::defer("Wait", 2.0, 1_000_000.0);
329        assert_eq!(defer.option_type, OptionType::Defer);
330
331        let expand = OptionDefinition::expand("Scale up", 1.5, 500_000.0);
332        assert_eq!(expand.option_type, OptionType::Expand);
333        assert_eq!(expand.expansion_factor, 1.5);
334
335        let abandon = OptionDefinition::abandon("Exit", 200_000.0);
336        assert_eq!(abandon.option_type, OptionType::Abandon);
337        assert_eq!(abandon.salvage_value, 200_000.0);
338    }
339}