Skip to main content

rustyqlib/equity/
handle_equity_contracts.rs

1use crate::core::errors::RustyQLibError;
2use crate::core::traits::Instrument;
3use crate::core::utils::{Contract,CombinedContract, ContractOutput};
4use crate::core::data_models::ProductData;
5use crate::equity::equity_forward::EquityForward;
6use crate::equity::vanilla_option::EquityOption;
7use crate::equity::equity_future::EquityFuture;
8
9/// Price one contract, reporting any failure in the output's `error` field
10/// so a batch of contracts always produces one result per contract. Typed
11/// errors come from validation and pricing; a panic escaping a numerical
12/// kernel is caught as a last resort and reported the same way.
13pub fn handle_equity_contract(data: &Contract) -> serde_json::Value {
14    let priced = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
15        price_equity_contract(data)
16    }));
17    let output = match priced {
18        Ok(Ok(output)) => output,
19        Ok(Err(e)) => ContractOutput::from_error(e.to_string()),
20        Err(payload) => {
21            let msg = payload
22                .downcast_ref::<&str>()
23                .map(|s| s.to_string())
24                .or_else(|| payload.downcast_ref::<String>().cloned())
25                .unwrap_or_else(|| "pricing panicked".to_string());
26            ContractOutput::from_error(msg)
27        }
28    };
29    if let Some(err) = &output.error {
30        log::warn!("contract error: {err}");
31    }
32    let combined_ = CombinedContract { contract: data.clone(), output };
33    serde_json::to_value(&combined_).expect("Failed to generate output")
34}
35
36fn price_equity_contract(data: &Contract) -> Result<ContractOutput, RustyQLibError> {
37    match &data.product_type {
38        ProductData::Option(opt) => {
39            let option = EquityOption::try_from_json(opt)?;
40            let contract_output = ContractOutput::from(option.price()?);
41            log::debug!("option pv {} delta {}", contract_output.pv, contract_output.delta);
42            Ok(contract_output)
43        }
44        ProductData::Future(fut) => {
45            let future = EquityFuture::try_from_json(fut)?;
46            let contract_output = ContractOutput::from(future.price()?);
47            log::debug!("equity future pv {}", contract_output.pv);
48            Ok(contract_output)
49        }
50        ProductData::Forward(forward) => {
51            let future = EquityForward::try_from_json(forward)?;
52            let contract_output = ContractOutput::from(future.price()?);
53            log::debug!("equity forward pv {}", contract_output.pv);
54            Ok(contract_output)
55        }
56        ProductData::RainbowOption(rb) => {
57            let option = crate::equity::rainbow::RainbowOption::try_from_json(rb)?;
58            // scalar spot Greeks are per-asset for rainbows: see deltas/vegas
59            let mut contract_output = ContractOutput::from(option.price()?);
60            contract_output.deltas = Some(option.deltas());
61            contract_output.vegas = Some(option.vegas());
62            log::debug!("rainbow option pv {}", contract_output.pv);
63            Ok(contract_output)
64        }
65        ProductData::CliquetOption(cq) => {
66            let cliquet = crate::equity::cliquet::Cliquet::try_from_json(cq)?;
67            let contract_output = ContractOutput::from(cliquet.price()?);
68            log::debug!("cliquet option pv {}", contract_output.pv);
69            Ok(contract_output)
70        }
71        ProductData::Accumulator(acc) => {
72            let accumulator = crate::equity::accumulator::Accumulator::try_from_json(acc)?;
73            let contract_output = ContractOutput::from(accumulator.price()?);
74            log::debug!("accumulator pv {}", contract_output.pv);
75            Ok(contract_output)
76        }
77        ProductData::VarianceSwap(vs) => {
78            let swap = crate::equity::variance_swap::VarianceSwap::try_from_json(vs)?;
79            let contract_output = ContractOutput::from(swap.price()?);
80            log::debug!(
81                "variance swap mtm {} (fair strike {:.4} vol)",
82                contract_output.pv,
83                swap.fair_remaining_variance.sqrt()
84            );
85            Ok(contract_output)
86        }
87        #[allow(unreachable_patterns)]
88        _ => Err(RustyQLibError::ParseError(
89            "unsupported or missing product_type for asset EQ".to_string(),
90        )),
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn contract(product: serde_json::Value) -> Contract {
99        serde_json::from_value(serde_json::json!({
100            "action": "PV",
101            "asset": "EQ",
102            "product_type": product,
103        }))
104        .expect("test contract must deserialize")
105    }
106
107    #[test]
108    fn invalid_contract_reports_error_instead_of_panicking() {
109        let bad = contract(serde_json::json!({
110            "product_type": "option",
111            "symbol": "ABC",
112            "underlying_price": 100.0,
113            "put_or_call": "C",
114            "payoff_type": "vanilla",
115            "strike_price": 100.0,
116            "volatility": 0.3,
117            "maturity": "2030-01-01",
118            "risk_free_rate": 0.05,
119            "pricer": "NoSuchEngine",
120        }));
121        let out = handle_equity_contract(&bad);
122        let err = out["output"]["error"].as_str().expect("error must be set");
123        assert!(err.contains("pricer"), "error should name the field: {err}");
124        assert_eq!(out["output"]["pv"], 0.0);
125    }
126
127    #[test]
128    fn unsupported_engine_combination_reports_error() {
129        // autocallable on the analytical engine is refused, not panicked
130        let bad = contract(serde_json::json!({
131            "product_type": "option",
132            "symbol": "ABC",
133            "underlying_price": 100.0,
134            "put_or_call": "C",
135            "payoff_type": "autocallable",
136            "autocall_barrier": 1.0,
137            "protection_barrier": 0.7,
138            "volatility": 0.3,
139            "maturity": "2030-01-01",
140            "risk_free_rate": 0.05,
141            "pricer": "Analytical",
142        }));
143        let out = handle_equity_contract(&bad);
144        let err = out["output"]["error"].as_str().expect("error must be set");
145        assert!(err.contains("MonteCarlo"), "should point at the right engine: {err}");
146    }
147
148    #[test]
149    fn explicit_valuation_date_prices_reproducibly() {
150        // fixed valuation and maturity: exactly one year, so the pv must
151        // hit the Black-Scholes golden value on any run date
152        let contract_json = contract(serde_json::json!({
153            "product_type": "option",
154            "symbol": "ABC",
155            "underlying_price": 100.0,
156            "put_or_call": "C",
157            "payoff_type": "vanilla",
158            "strike_price": 100.0,
159            "volatility": 0.3,
160            "valuation_date": "2026-01-01",
161            "maturity": "2027-01-01",
162            "risk_free_rate": 0.05,
163            "pricer": "Analytical",
164        }));
165        let out = handle_equity_contract(&contract_json);
166        assert!(out["output"]["error"].is_null());
167        let pv = out["output"]["pv"].as_f64().unwrap();
168        assert!((pv - 14.2312547860).abs() < 1e-8, "pv {pv} must be date-independent");
169    }
170
171    #[test]
172    fn bad_or_expired_valuation_dates_are_rejected() {
173        let bad_date = contract(serde_json::json!({
174            "product_type": "option",
175            "symbol": "ABC",
176            "underlying_price": 100.0,
177            "put_or_call": "C",
178            "payoff_type": "vanilla",
179            "strike_price": 100.0,
180            "volatility": 0.3,
181            "valuation_date": "01/01/2026",
182            "maturity": "2027-01-01",
183            "risk_free_rate": 0.05,
184            "pricer": "Analytical",
185        }));
186        let out = handle_equity_contract(&bad_date);
187        let err = out["output"]["error"].as_str().expect("error must be set");
188        assert!(err.contains("valuation_date"), "error should name the field: {err}");
189
190        // valuation after maturity: expired, refused
191        let expired = contract(serde_json::json!({
192            "product_type": "option",
193            "symbol": "ABC",
194            "underlying_price": 100.0,
195            "put_or_call": "C",
196            "payoff_type": "vanilla",
197            "strike_price": 100.0,
198            "volatility": 0.3,
199            "valuation_date": "2028-01-01",
200            "maturity": "2027-01-01",
201            "risk_free_rate": 0.05,
202            "pricer": "Analytical",
203        }));
204        let out = handle_equity_contract(&expired);
205        let err = out["output"]["error"].as_str().expect("error must be set");
206        assert!(err.contains("maturity"), "error should name the field: {err}");
207    }
208
209    #[test]
210    fn bermudan_contract_prices_and_requires_dates() {
211        let berm = contract(serde_json::json!({
212            "product_type": "option",
213            "symbol": "ABC",
214            "underlying_price": 100.0,
215            "put_or_call": "P",
216            "payoff_type": "vanilla",
217            "exercise_style": "Bermudan",
218            "exercise_dates": ["2026-04-06", "2026-07-06", "2026-10-05"],
219            "strike_price": 100.0,
220            "volatility": 0.3,
221            "valuation_date": "2026-01-05",
222            "maturity": "2027-01-04",
223            "risk_free_rate": 0.05,
224            "pricer": "Binomial",
225        }));
226        let out = handle_equity_contract(&berm);
227        assert!(out["output"]["error"].is_null(), "error: {:?}", out["output"]["error"]);
228        let pv = out["output"]["pv"].as_f64().unwrap();
229        // between the European and American puts for these parameters
230        assert!(pv > 9.0 && pv < 11.5, "Bermudan put pv {pv} out of range");
231
232        // Bermudan without dates is rejected naming the field
233        let missing = contract(serde_json::json!({
234            "product_type": "option",
235            "symbol": "ABC",
236            "underlying_price": 100.0,
237            "put_or_call": "P",
238            "payoff_type": "vanilla",
239            "exercise_style": "Bermudan",
240            "strike_price": 100.0,
241            "volatility": 0.3,
242            "valuation_date": "2026-01-05",
243            "maturity": "2027-01-04",
244            "risk_free_rate": 0.05,
245            "pricer": "Binomial",
246        }));
247        let out = handle_equity_contract(&missing);
248        let err = out["output"]["error"].as_str().expect("error must be set");
249        assert!(err.contains("exercise_dates"), "error should name the field: {err}");
250    }
251
252    #[test]
253    fn tree_type_flows_through_the_contract() {
254        let priced = |tree: &str| {
255            let c = contract(serde_json::json!({
256                "product_type": "option",
257                "symbol": "ABC",
258                "underlying_price": 100.0,
259                "put_or_call": "P",
260                "payoff_type": "vanilla",
261                "exercise_style": "American",
262                "strike_price": 100.0,
263                "volatility": 0.3,
264                "valuation_date": "2026-01-05",
265                "maturity": "2027-01-05",
266                "risk_free_rate": 0.05,
267                "pricer": "Binomial",
268                "tree_type": tree,
269                "tree_steps": 501,
270            }));
271            handle_equity_contract(&c)
272        };
273        let lr = priced("LeisenReimer");
274        assert!(lr["output"]["error"].is_null());
275        let crr = priced("CRR");
276        let (lr_pv, crr_pv) =
277            (lr["output"]["pv"].as_f64().unwrap(), crr["output"]["pv"].as_f64().unwrap());
278        assert!((lr_pv - crr_pv).abs() < 0.05, "schemes agree loosely: {lr_pv} vs {crr_pv}");
279        // unknown scheme is rejected naming the field
280        let bad = priced("no_such_tree");
281        let err = bad["output"]["error"].as_str().expect("error must be set");
282        assert!(err.contains("tree_type"), "{err}");
283    }
284
285    #[test]
286    fn valid_contract_still_prices_with_no_error() {
287        let good = contract(serde_json::json!({
288            "product_type": "option",
289            "symbol": "ABC",
290            "underlying_price": 100.0,
291            "put_or_call": "C",
292            "payoff_type": "vanilla",
293            "strike_price": 100.0,
294            "volatility": 0.3,
295            "maturity": "2030-01-01",
296            "risk_free_rate": 0.05,
297            "pricer": "Analytical",
298        }));
299        let out = handle_equity_contract(&good);
300        assert!(out["output"]["error"].is_null());
301        assert!(out["output"]["pv"].as_f64().unwrap() > 0.0);
302    }
303}