1use crate::contract::Currency;
16use crate::finance::{Date, FixedRateBond};
17use crate::option::{OptionStyle, OptionType};
18use crate::portfolio::PositionSide;
19
20use super::{ValuationContext, ValuationContextError, ValuationStamp, Valued};
21
22#[cfg(feature = "serde")]
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Clone, PartialEq)]
27#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
28pub enum ValuedInstrument {
29 Linear {
31 price: f64,
32 multiplier: f64,
34 },
35 EuropeanOption {
41 option_type: OptionType,
42 style: OptionStyle,
43 spot: f64,
44 strike: f64,
45 expiry: Date,
46 volatility: f64,
47 dividend_yield: f64,
48 contract_size: f64,
50 },
51 Bond { bond: Box<FixedRateBond> },
55}
56
57#[derive(Debug, Clone, PartialEq)]
59#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
60pub struct ValuationPosition {
61 pub symbol: String,
62 pub currency: Currency,
63 pub side: PositionSide,
64 pub quantity: f64,
66 pub instrument: ValuedInstrument,
67}
68
69impl ValuationPosition {
70 pub fn new(
71 symbol: impl Into<String>,
72 currency: Currency,
73 side: PositionSide,
74 quantity: f64,
75 instrument: ValuedInstrument,
76 ) -> Self {
77 Self {
78 symbol: symbol.into(),
79 currency,
80 side,
81 quantity,
82 instrument,
83 }
84 }
85
86 fn signed_quantity(&self) -> f64 {
87 match self.side {
88 PositionSide::Long => self.quantity,
89 PositionSide::Short => -self.quantity,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96#[cfg_attr(
97 feature = "serde",
98 derive(Serialize, Deserialize),
99 serde(rename_all = "snake_case")
100)]
101pub enum ValuationModel {
102 Linear,
104 BlackScholesMerton,
106 DiscountedCashflows,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq)]
113#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
114pub struct MarketScenario {
115 pub rate_shift: f64,
120 pub underlying_shock_pct: f64,
122 pub volatility_shift: f64,
125 pub fx_shock_pct: f64,
128}
129
130impl Default for MarketScenario {
131 fn default() -> Self {
132 Self::neutral()
133 }
134}
135
136impl MarketScenario {
137 pub fn neutral() -> Self {
139 Self {
140 rate_shift: 0.0,
141 underlying_shock_pct: 0.0,
142 volatility_shift: 0.0,
143 fx_shock_pct: 0.0,
144 }
145 }
146
147 pub fn with_rate_shift(mut self, shift: f64) -> Self {
148 self.rate_shift = shift;
149 self
150 }
151
152 pub fn with_underlying_shock(mut self, pct: f64) -> Self {
153 self.underlying_shock_pct = pct;
154 self
155 }
156
157 pub fn with_volatility_shift(mut self, shift: f64) -> Self {
158 self.volatility_shift = shift;
159 self
160 }
161
162 pub fn with_fx_shock(mut self, pct: f64) -> Self {
163 self.fx_shock_pct = pct;
164 self
165 }
166
167 pub fn validate(&self) -> Result<(), ValuationContextError> {
168 let finite = self.rate_shift.is_finite()
169 && self.underlying_shock_pct.is_finite()
170 && self.volatility_shift.is_finite()
171 && self.fx_shock_pct.is_finite();
172 if !finite {
173 return Err(ValuationContextError::InvalidScenario(
174 "every shock must be finite",
175 ));
176 }
177 if self.underlying_shock_pct < -1.0 {
178 return Err(ValuationContextError::InvalidScenario(
179 "underlying_shock_pct must be >= -1.0",
180 ));
181 }
182 if self.fx_shock_pct < -1.0 {
183 return Err(ValuationContextError::InvalidScenario(
184 "fx_shock_pct must be >= -1.0",
185 ));
186 }
187 Ok(())
188 }
189}
190
191#[derive(Debug, Clone, PartialEq)]
193#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
194pub struct PositionValuation {
195 pub symbol: String,
196 pub currency: Currency,
197 pub signed_quantity: f64,
199 pub unit_value: f64,
201 pub value: f64,
203 pub value_account: f64,
205 pub model: ValuationModel,
206}
207
208#[derive(Debug, Clone, PartialEq)]
210#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
211pub struct PortfolioValuation {
212 pub positions: Vec<PositionValuation>,
213 pub account_currency: Currency,
214 pub total_value_account: f64,
215}
216
217#[derive(Debug, Clone, PartialEq)]
220#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
221pub struct PortfolioScenarioResult {
222 pub base: PortfolioValuation,
223 pub stressed: PortfolioValuation,
224 pub pnl_account: f64,
226 pub sensitivities: PortfolioSensitivities,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq)]
236#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
237pub struct PortfolioSensitivities {
238 pub underlying_up_1pct: f64,
240 pub volatility_up_1pt: f64,
242 pub rate_up_1bp: f64,
244 pub fx_up_1pct: f64,
246}
247
248impl ValuationContext {
249 pub fn value_position(
251 &self,
252 position: &ValuationPosition,
253 account_currency: &Currency,
254 scenario: &MarketScenario,
255 ) -> Result<PositionValuation, ValuationContextError> {
256 scenario.validate()?;
257
258 let (unit_value, model) = match &position.instrument {
259 ValuedInstrument::Linear { price, multiplier } => {
260 let shocked = price * (1.0 + scenario.underlying_shock_pct);
261 (shocked * multiplier, ValuationModel::Linear)
262 }
263 ValuedInstrument::EuropeanOption {
264 option_type,
265 style,
266 spot,
267 strike,
268 expiry,
269 volatility,
270 dividend_yield,
271 contract_size,
272 } => {
273 if *style != OptionStyle::European {
274 return Err(ValuationContextError::UnsupportedExercise(*style));
275 }
276 let priced = self.price_european_option_shifted(
277 &position.currency,
278 *option_type,
279 spot * (1.0 + scenario.underlying_shock_pct),
280 *strike,
281 *expiry,
282 (volatility + scenario.volatility_shift).max(0.0),
283 *dividend_yield,
284 scenario.rate_shift,
285 )?;
286 (
287 priced.value.price * contract_size,
288 ValuationModel::BlackScholesMerton,
289 )
290 }
291 ValuedInstrument::Bond { bond } => {
292 let priced =
293 self.price_bond_shifted(bond, &position.currency, scenario.rate_shift)?;
294 (
295 priced.value.dirty_price,
296 ValuationModel::DiscountedCashflows,
297 )
298 }
299 };
300
301 let signed_quantity = position.signed_quantity();
302 let value = unit_value * signed_quantity;
303 let converted = self.convert(value, &position.currency, account_currency)?;
304 let value_account = if position.currency == *account_currency {
305 converted
306 } else {
307 converted * (1.0 + scenario.fx_shock_pct)
308 };
309
310 Ok(PositionValuation {
311 symbol: position.symbol.clone(),
312 currency: position.currency.clone(),
313 signed_quantity,
314 unit_value,
315 value,
316 value_account,
317 model,
318 })
319 }
320
321 pub fn value_portfolio(
323 &self,
324 positions: &[ValuationPosition],
325 account_currency: &Currency,
326 scenario: &MarketScenario,
327 ) -> Result<Valued<PortfolioValuation>, ValuationContextError> {
328 let mut valued = Vec::with_capacity(positions.len());
329 let mut total = 0.0;
330 for position in positions {
331 let one = self.value_position(position, account_currency, scenario)?;
332 total += one.value_account;
333 valued.push(one);
334 }
335
336 Ok(Valued {
337 value: PortfolioValuation {
338 positions: valued,
339 account_currency: account_currency.clone(),
340 total_value_account: total,
341 },
342 stamp: self.stamp(),
343 })
344 }
345
346 pub fn stress_portfolio(
349 &self,
350 positions: &[ValuationPosition],
351 account_currency: &Currency,
352 scenario: &MarketScenario,
353 ) -> Result<Valued<PortfolioScenarioResult>, ValuationContextError> {
354 let base = self.value_portfolio(positions, account_currency, &MarketScenario::neutral())?;
355 let stressed = self.value_portfolio(positions, account_currency, scenario)?;
356
357 let base_total = base.value.total_value_account;
358 let measure = |scenario: MarketScenario| -> Result<f64, ValuationContextError> {
359 Ok(self
360 .value_portfolio(positions, account_currency, &scenario)?
361 .value
362 .total_value_account
363 - base_total)
364 };
365
366 let sensitivities = PortfolioSensitivities {
367 underlying_up_1pct: measure(MarketScenario::neutral().with_underlying_shock(0.01))?,
368 volatility_up_1pt: measure(MarketScenario::neutral().with_volatility_shift(0.01))?,
369 rate_up_1bp: measure(MarketScenario::neutral().with_rate_shift(0.0001))?,
370 fx_up_1pct: measure(MarketScenario::neutral().with_fx_shock(0.01))?,
371 };
372
373 Ok(Valued {
374 value: PortfolioScenarioResult {
375 pnl_account: stressed.value.total_value_account - base_total,
376 base: base.value,
377 stressed: stressed.value,
378 sensitivities,
379 },
380 stamp: self.stamp(),
381 })
382 }
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
391#[cfg_attr(
392 feature = "serde",
393 derive(Serialize, Deserialize),
394 serde(rename_all = "snake_case")
395)]
396pub enum SensitivityKind {
397 UnderlyingUpOnePercent,
398 VolatilityUpOnePoint,
399 RateUpOneBasisPoint,
400 FxUpOnePercent,
401}
402
403impl SensitivityKind {
404 pub fn described_move(&self) -> &'static str {
406 match self {
407 Self::UnderlyingUpOnePercent => "every underlying +1%",
408 Self::VolatilityUpOnePoint => "every volatility +1 point",
409 Self::RateUpOneBasisPoint => "every zero rate +1 bp",
410 Self::FxUpOnePercent => "every foreign currency +1% against the account currency",
411 }
412 }
413}
414
415impl PortfolioSensitivities {
416 pub fn entries(&self) -> [(SensitivityKind, f64); 4] {
418 [
419 (
420 SensitivityKind::UnderlyingUpOnePercent,
421 self.underlying_up_1pct,
422 ),
423 (
424 SensitivityKind::VolatilityUpOnePoint,
425 self.volatility_up_1pt,
426 ),
427 (SensitivityKind::RateUpOneBasisPoint, self.rate_up_1bp),
428 (SensitivityKind::FxUpOnePercent, self.fx_up_1pct),
429 ]
430 }
431}
432
433#[derive(Debug, Clone, PartialEq)]
452#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
453pub struct PortfolioReport {
454 pub stamp: ValuationStamp,
455 pub account_currency: Currency,
456 pub base_value_account: f64,
458 pub scenario_value_account: f64,
460 pub pnl_account: f64,
462 pub positions: Vec<PositionValuation>,
464 pub sensitivities: Vec<(SensitivityKind, f64)>,
465}
466
467impl PortfolioReport {
468 pub fn from_scenario(result: &Valued<PortfolioScenarioResult>) -> Self {
470 Self {
471 stamp: result.stamp.clone(),
472 account_currency: result.value.base.account_currency.clone(),
473 base_value_account: result.value.base.total_value_account,
474 scenario_value_account: result.value.stressed.total_value_account,
475 pnl_account: result.value.pnl_account,
476 positions: result.value.base.positions.clone(),
477 sensitivities: result.value.sensitivities.entries().to_vec(),
478 }
479 }
480}