Skip to main content

kestrel_chartkit/valuation/
portfolio.rs

1//! Positions valued by model, revalued under market scenarios, aggregated in account currency.
2//!
3//! [`crate::portfolio::evaluate_portfolio`] answers a different question: it takes a price per
4//! position and works out exposure and stop risk from it. That is the right tool when a price is
5//! all there is. It cannot revalue an option after a volatility move or a bond after a rate move,
6//! because a percentage shock to a price is not a repricing of a non-linear instrument.
7//!
8//! Here a position says *what it is*, and the value follows from a [`ValuationContext`]. A
9//! scenario shocks the market data, not the results, and everything is then computed again from
10//! scratch — which is also where the sensitivities come from: each one is the portfolio revalued
11//! under a defined, named move. They are therefore consistent with the valuation by construction,
12//! summable in account currency across instrument types, and cannot drift apart from the prices
13//! they belong to.
14
15use 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/// What a position holds, in enough detail to value it.
26#[derive(Debug, Clone, PartialEq)]
27#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
28pub enum ValuedInstrument {
29    /// Valued straight from its price: `price * multiplier` per unit. Equities, futures, CFDs.
30    Linear {
31        price: f64,
32        /// Contract point multiplier, `1.0` for a plain share.
33        multiplier: f64,
34    },
35    /// A European option on a spot underlying, valued with Black-Scholes-Merton.
36    ///
37    /// `style` is carried so an American contract can be *refused* rather than quietly valued as
38    /// if early exercise were worthless. There is no American engine in this crate, and pricing
39    /// one with a European formula would understate it without saying so.
40    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        /// Underlying units per contract.
49        contract_size: f64,
50    },
51    /// A fixed-rate bond, valued by discounting its remaining cashflows on the currency's curve.
52    ///
53    /// The value is the dirty price for one bond of the schedule's face value.
54    Bond { bond: Box<FixedRateBond> },
55}
56
57/// A position to be valued: what it is, how much of it, and in which currency it trades.
58#[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    /// Absolute size; direction comes from `side`.
65    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/// Which model produced a position's value.
95#[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    /// Price times multiplier.
103    Linear,
104    /// Black-Scholes-Merton with the zero rate to expiry.
105    BlackScholesMerton,
106    /// Cashflows discounted on the currency's curve.
107    DiscountedCashflows,
108}
109
110/// Shocks applied to the market data before revaluing. Every field is neutral at its zero value,
111/// and the unit is part of the field, not of the caller's memory.
112#[derive(Debug, Clone, Copy, PartialEq)]
113#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
114pub struct MarketScenario {
115    /// Parallel shift of every zero rate, in absolute rate units: `0.0001` is one basis point.
116    ///
117    /// Absolute rather than relative, because a relative shift does nothing at a zero rate and
118    /// points the wrong way at a negative one.
119    pub rate_shift: f64,
120    /// Relative move of every underlying spot price: `-0.10` is minus ten percent.
121    pub underlying_shock_pct: f64,
122    /// Absolute shift of every volatility, in volatility points: `0.05` moves 20% vol to 25%.
123    /// A shifted volatility is floored at zero.
124    pub volatility_shift: f64,
125    /// Relative move of every foreign currency against the account currency: `-0.05` makes
126    /// foreign holdings worth five percent less in account currency.
127    pub fx_shock_pct: f64,
128}
129
130impl Default for MarketScenario {
131    fn default() -> Self {
132        Self::neutral()
133    }
134}
135
136impl MarketScenario {
137    /// No shocks. Revaluing under it must reproduce the base valuation exactly.
138    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/// One position's value under one scenario.
192#[derive(Debug, Clone, PartialEq)]
193#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
194pub struct PositionValuation {
195    pub symbol: String,
196    pub currency: Currency,
197    /// Positive for long, negative for short.
198    pub signed_quantity: f64,
199    /// Value of one unit, in the position's own currency.
200    pub unit_value: f64,
201    /// `unit_value * signed_quantity`, in the position's own currency.
202    pub value: f64,
203    /// The same value in account currency.
204    pub value_account: f64,
205    pub model: ValuationModel,
206}
207
208/// A portfolio's value under one scenario.
209#[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/// Base value, scenario value, and the difference — plus the sensitivities, each of which is
218/// itself a revaluation under a named move.
219#[derive(Debug, Clone, PartialEq)]
220#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
221pub struct PortfolioScenarioResult {
222    pub base: PortfolioValuation,
223    pub stressed: PortfolioValuation,
224    /// `stressed - base`, in account currency.
225    pub pnl_account: f64,
226    pub sensitivities: PortfolioSensitivities,
227}
228
229/// Value changes under one defined move each, all in account currency and all measured by
230/// revaluing rather than by a closed-form derivative.
231///
232/// They are differences, not derivatives: a large move is not the small move scaled up, and for a
233/// non-linear position the two differ — which is the honest answer, since that difference is what
234/// convexity is.
235#[derive(Debug, Clone, Copy, PartialEq)]
236#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
237pub struct PortfolioSensitivities {
238    /// Change if every underlying rises one percent.
239    pub underlying_up_1pct: f64,
240    /// Change if every volatility rises one point (e.g. 20% to 21%).
241    pub volatility_up_1pt: f64,
242    /// Change if every zero rate rises one basis point.
243    pub rate_up_1bp: f64,
244    /// Change if every foreign currency gains one percent against the account currency.
245    pub fx_up_1pct: f64,
246}
247
248impl ValuationContext {
249    /// Values one position under a scenario.
250    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    /// Values every position under one scenario and sums them in account currency.
322    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    /// Values the portfolio as it stands and under `scenario`, and measures the sensitivities by
347    /// revaluing under one defined move each.
348    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/// One named sensitivity, with the move it measures.
386///
387/// The kind is part of the result rather than a string a consumer invents: a report that labels
388/// "rate" without saying *how much* rate has labelled nothing, and a reader who assumes basis
389/// points where percent was meant is off by four orders of magnitude.
390#[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    /// The move this sensitivity is the answer to, ready to put next to the number.
405    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    /// The four sensitivities with their kinds, for a consumer that renders them as a list.
417    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/// What a consumer carries onward from a valuation.
434///
435/// The pieces exist separately — [`Valued`], [`PortfolioScenarioResult`], [`ValuationModel`] —
436/// and a report could assemble them itself. This type is the assembled form, so that every
437/// consumer assembles it the same way and none of them re-derives what a number means.
438///
439/// What the contract says, and what a consumer must not do with it:
440///
441/// * Every figure is in `account_currency`, at the [`ValuationStamp`] given. A number without its
442///   stamp cannot be traced back to the data it came from, so they travel together.
443/// * `positions` carries each position's [`ValuationModel`]. A display that puts a linearly
444///   valued position next to a model-valued one without distinction blurs exactly what the
445///   valuation was for.
446/// * `sensitivities` are *differences* under the named moves, not derivatives. Scaling one up to
447///   a larger move is not permitted: for a non-linear position the two disagree, and that
448///   disagreement is the convexity the number was supposed to expose.
449/// * A valuation this crate does not support comes back as an error, never as a zero. A consumer
450///   that renders a missing value as `0.00` has reported a position worth nothing.
451#[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    /// Value before the scenario.
457    pub base_value_account: f64,
458    /// Value under the scenario.
459    pub scenario_value_account: f64,
460    /// `scenario_value_account - base_value_account`.
461    pub pnl_account: f64,
462    /// Position level of the base valuation, each with the model that produced it.
463    pub positions: Vec<PositionValuation>,
464    pub sensitivities: Vec<(SensitivityKind, f64)>,
465}
466
467impl PortfolioReport {
468    /// Flattens a scenario result into the consumer-facing form.
469    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}