Skip to main content

kestrel_chartkit/
portfolio.rs

1//! Provider-neutral portfolio exposure, cashflow-adjusted equity returns, and risk analytics.
2//!
3//! Evaluates open positions, cash balances, and margin in an explicit account currency.
4//! Computes gross/net exposures, aggregated stop risk, drawdowns, cashflow-adjusted returns,
5//! Sharpe/Sortino ratios, and non-parametric historical Value at Risk (VaR) / Expected Shortfall.
6
7use std::fmt;
8
9#[cfg(feature = "serde")]
10use serde::{Deserialize, Serialize};
11
12use crate::contract::{ContractSpec, Currency, ValuationError};
13
14/// Direction of an open position.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16#[cfg_attr(
17    feature = "serde",
18    derive(Serialize, Deserialize),
19    serde(rename_all = "snake_case")
20)]
21pub enum PositionSide {
22    Long,
23    Short,
24}
25
26/// A snapshot of an individual open position.
27#[derive(Debug, Clone, PartialEq)]
28#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
29pub struct PositionSnapshot {
30    pub symbol: String,
31    pub spec: ContractSpec,
32    pub side: PositionSide,
33    /// Absolute quantity (must be positive).
34    pub quantity: f64,
35    pub entry_price: f64,
36    pub current_price: f64,
37    /// Optional stop-loss price. Stops are modeled as price levels, not guaranteed execution prices.
38    pub stop_price: Option<f64>,
39    /// Conversion rate from `spec.price_currency` to account currency.
40    pub fx_to_account: f64,
41}
42
43/// Evaluated metrics for an individual position in account currency.
44#[derive(Debug, Clone, PartialEq)]
45#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
46pub struct PositionEvaluation {
47    pub symbol: String,
48    pub side: PositionSide,
49    pub quantity: f64,
50    pub notional: f64,
51    pub unrealized_pnl: f64,
52    /// Stop risk in account currency, or `None` if no stop is configured.
53    pub stop_risk: Option<f64>,
54}
55
56/// Account-level cash and ledger state.
57#[derive(Debug, Clone, PartialEq)]
58#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
59pub struct CashLedger {
60    /// Available cash balance in account currency.
61    pub cash: f64,
62    /// Cumulative deposits into the account.
63    pub cumulative_deposits: f64,
64    /// Cumulative withdrawals from the account.
65    pub cumulative_withdrawals: f64,
66    /// Cumulative fees and commissions paid.
67    pub cumulative_fees: f64,
68    /// Cumulative realized P&L from closed trades.
69    pub cumulative_realized_pnl: f64,
70}
71
72impl Default for CashLedger {
73    fn default() -> Self {
74        Self {
75            cash: 0.0,
76            cumulative_deposits: 0.0,
77            cumulative_withdrawals: 0.0,
78            cumulative_fees: 0.0,
79            cumulative_realized_pnl: 0.0,
80        }
81    }
82}
83
84/// Comprehensive portfolio valuation snapshot in account currency.
85#[derive(Debug, Clone, PartialEq)]
86#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
87pub struct PortfolioSnapshot {
88    pub account_currency: Currency,
89    /// Total equity = cash + unrealized P&L.
90    pub equity: f64,
91    pub cash: f64,
92    pub unrealized_pnl: f64,
93    pub cumulative_realized_pnl: f64,
94    pub cumulative_fees: f64,
95    /// Net external capital invested = cumulative deposits - cumulative withdrawals.
96    pub net_deposits: f64,
97    /// Sum of all long position notionals in account currency.
98    pub long_notional: f64,
99    /// Sum of all short position notionals in account currency.
100    pub short_notional: f64,
101    /// Gross exposure = long notional + short notional.
102    pub gross_exposure: f64,
103    /// Net exposure = long notional - short notional.
104    pub net_exposure: f64,
105    /// Gross leverage = gross exposure / equity (0.0 if equity <= 0).
106    pub gross_leverage: f64,
107    /// Net leverage = net exposure / equity (0.0 if equity <= 0).
108    pub net_leverage: f64,
109    /// Aggregated stop risk across all positions with configured stops.
110    /// Note: Stops do not guarantee loss limits during gap openings or illiquid periods.
111    pub total_stop_risk: f64,
112    /// Concentration of the largest position: `largest_position_notional / gross_exposure`.
113    pub max_position_concentration: f64,
114    /// Evaluated individual positions.
115    pub positions: Vec<PositionEvaluation>,
116}
117
118/// Errors when evaluating a portfolio.
119#[derive(Debug, Clone, PartialEq)]
120pub enum PortfolioError {
121    Valuation(ValuationError),
122    InvalidInput(&'static str),
123}
124
125impl fmt::Display for PortfolioError {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match self {
128            Self::Valuation(e) => write!(f, "valuation error: {e}"),
129            Self::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
130        }
131    }
132}
133
134impl std::error::Error for PortfolioError {}
135
136impl From<ValuationError> for PortfolioError {
137    fn from(e: ValuationError) -> Self {
138        Self::Valuation(e)
139    }
140}
141
142/// Evaluates a portfolio snapshot from open positions and cash ledger.
143pub fn evaluate_portfolio(
144    account_currency: Currency,
145    ledger: &CashLedger,
146    positions: &[PositionSnapshot],
147) -> Result<PortfolioSnapshot, PortfolioError> {
148    if !ledger.cash.is_finite() {
149        return Err(PortfolioError::InvalidInput("cash must be finite"));
150    }
151
152    let mut long_notional = 0.0f64;
153    let mut short_notional = 0.0f64;
154    let mut total_unrealized_pnl = 0.0f64;
155    let mut total_stop_risk = 0.0f64;
156    let mut largest_notional = 0.0f64;
157    let mut evaluated_positions = Vec::with_capacity(positions.len());
158
159    for pos in positions {
160        if !pos.quantity.is_finite() || pos.quantity <= 0.0 {
161            return Err(PortfolioError::InvalidInput(
162                "position quantity must be positive and finite",
163            ));
164        }
165        if !pos.entry_price.is_finite() || pos.entry_price <= 0.0 {
166            return Err(PortfolioError::InvalidInput(
167                "entry price must be positive and finite",
168            ));
169        }
170        if !pos.current_price.is_finite() || pos.current_price <= 0.0 {
171            return Err(PortfolioError::InvalidInput(
172                "current price must be positive and finite",
173            ));
174        }
175        if !pos.fx_to_account.is_finite() || pos.fx_to_account <= 0.0 {
176            return Err(PortfolioError::InvalidInput(
177                "fx_to_account must be positive and finite",
178            ));
179        }
180        pos.spec
181            .validate()
182            .map_err(|e| PortfolioError::Valuation(ValuationError::InvalidContract(e)))?;
183
184        let notional = pos.quantity * pos.current_price * pos.spec.multiplier * pos.fx_to_account;
185        largest_notional = largest_notional.max(notional);
186
187        let pnl_diff = match pos.side {
188            PositionSide::Long => pos.current_price - pos.entry_price,
189            PositionSide::Short => pos.entry_price - pos.current_price,
190        };
191        let unrealized_pnl = pnl_diff * pos.quantity * pos.spec.multiplier * pos.fx_to_account;
192        total_unrealized_pnl += unrealized_pnl;
193
194        let stop_risk = if let Some(stop) = pos.stop_price {
195            if !stop.is_finite() || stop <= 0.0 {
196                return Err(PortfolioError::InvalidInput(
197                    "stop price must be positive and finite",
198                ));
199            }
200            let risk_diff = (pos.entry_price - stop).abs();
201            let risk_amount = risk_diff * pos.quantity * pos.spec.multiplier * pos.fx_to_account;
202            total_stop_risk += risk_amount;
203            Some(risk_amount)
204        } else {
205            None
206        };
207
208        match pos.side {
209            PositionSide::Long => long_notional += notional,
210            PositionSide::Short => short_notional += notional,
211        }
212
213        evaluated_positions.push(PositionEvaluation {
214            symbol: pos.symbol.clone(),
215            side: pos.side,
216            quantity: pos.quantity,
217            notional,
218            unrealized_pnl,
219            stop_risk,
220        });
221    }
222
223    let equity = ledger.cash + total_unrealized_pnl;
224    let gross_exposure = long_notional + short_notional;
225    let net_exposure = long_notional - short_notional;
226
227    let gross_leverage = if equity > 0.0 {
228        gross_exposure / equity
229    } else {
230        0.0
231    };
232    let net_leverage = if equity > 0.0 {
233        net_exposure / equity
234    } else {
235        0.0
236    };
237    let max_position_concentration = if gross_exposure > 0.0 {
238        largest_notional / gross_exposure
239    } else {
240        0.0
241    };
242    let net_deposits = ledger.cumulative_deposits - ledger.cumulative_withdrawals;
243
244    Ok(PortfolioSnapshot {
245        account_currency,
246        equity,
247        cash: ledger.cash,
248        unrealized_pnl: total_unrealized_pnl,
249        cumulative_realized_pnl: ledger.cumulative_realized_pnl,
250        cumulative_fees: ledger.cumulative_fees,
251        net_deposits,
252        long_notional,
253        short_notional,
254        gross_exposure,
255        net_exposure,
256        gross_leverage,
257        net_leverage,
258        total_stop_risk,
259        max_position_concentration,
260        positions: evaluated_positions,
261    })
262}
263
264/// Drawdown statistics across an equity curve.
265#[derive(Debug, Clone, Copy, PartialEq)]
266#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
267pub struct DrawdownStats {
268    pub peak_equity: f64,
269    pub current_drawdown: f64,
270    pub current_drawdown_pct: f64,
271    pub max_drawdown: f64,
272    pub max_drawdown_pct: f64,
273    pub max_drawdown_duration_bars: usize,
274}
275
276/// Computes peak equity, current and maximum drawdown (amount and percentage), and duration.
277pub fn compute_drawdown(equity_series: &[f64]) -> DrawdownStats {
278    if equity_series.is_empty() {
279        return DrawdownStats {
280            peak_equity: 0.0,
281            current_drawdown: 0.0,
282            current_drawdown_pct: 0.0,
283            max_drawdown: 0.0,
284            max_drawdown_pct: 0.0,
285            max_drawdown_duration_bars: 0,
286        };
287    }
288
289    let mut peak: f64 = equity_series[0];
290    let mut max_dd: f64 = 0.0;
291    let mut max_dd_pct: f64 = 0.0;
292    let mut current_duration = 0;
293    let mut max_duration = 0;
294
295    for &eq in equity_series {
296        if eq >= peak {
297            peak = eq;
298            current_duration = 0;
299        } else {
300            current_duration += 1;
301            max_duration = max_duration.max(current_duration);
302            let dd = peak - eq;
303            let dd_pct = if peak > 0.0 { dd / peak } else { 0.0 };
304            max_dd = max_dd.max(dd);
305            max_dd_pct = max_dd_pct.max(dd_pct);
306        }
307    }
308
309    let last_eq = *equity_series.last().unwrap();
310    let current_dd = (peak - last_eq).max(0.0);
311    let current_dd_pct = if peak > 0.0 { current_dd / peak } else { 0.0 };
312
313    DrawdownStats {
314        peak_equity: peak,
315        current_drawdown: current_dd,
316        current_drawdown_pct: current_dd_pct,
317        max_drawdown: max_dd,
318        max_drawdown_pct: max_dd_pct,
319        max_drawdown_duration_bars: max_duration,
320    }
321}
322
323/// Aggregated return and risk metrics across a series of periodic returns.
324#[derive(Debug, Clone, Copy, PartialEq)]
325#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
326pub struct ReturnMetrics {
327    /// Arithmetic mean periodic return.
328    pub mean_return: f64,
329    /// Annualized return: `mean_return * periods_per_year`.
330    pub annualized_return: f64,
331    /// Annualized volatility (sample standard deviation): `std_dev * sqrt(periods_per_year)`.
332    pub annualized_volatility: f64,
333    /// Annualized Sharpe ratio: `(mean_return - rf_per_period) / std_dev * sqrt(periods_per_year)`.
334    pub sharpe_ratio: f64,
335    /// Annualized Sortino ratio: `(mean_return - rf_per_period) / downside_deviation * sqrt(periods_per_year)`.
336    pub sortino_ratio: f64,
337    /// Number of return periods evaluated.
338    pub sample_count: usize,
339}
340
341/// Computes annualized return, volatility, Sharpe ratio, and Sortino ratio.
342///
343/// - `returns`: slice of fractional periodic returns (e.g. `0.01` for +1%).
344/// - `annual_risk_free_rate`: annualized risk-free rate (e.g. `0.03` for 3%).
345/// - `periods_per_year`: number of periods per calendar year (e.g. 252 for daily, 12 for monthly).
346pub fn calculate_return_metrics(
347    returns: &[f64],
348    annual_risk_free_rate: f64,
349    periods_per_year: f64,
350) -> Option<ReturnMetrics> {
351    if returns.len() < 2 || periods_per_year <= 0.0 {
352        return None;
353    }
354
355    let n = returns.len() as f64;
356    let mean = returns.iter().sum::<f64>() / n;
357    let rf_per_period = annual_risk_free_rate / periods_per_year;
358
359    // Sample variance & standard deviation
360    let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
361    let std_dev = variance.sqrt();
362
363    // Downside deviation relative to rf_per_period
364    let downside_variance = returns
365        .iter()
366        .map(|r| {
367            let under = (r - rf_per_period).min(0.0);
368            under * under
369        })
370        .sum::<f64>()
371        / n;
372    let downside_dev = downside_variance.sqrt();
373
374    let ann_factor = periods_per_year.sqrt();
375    let annualized_return = mean * periods_per_year;
376    let annualized_volatility = std_dev * ann_factor;
377
378    let sharpe_ratio = if std_dev > 1e-12 {
379        ((mean - rf_per_period) / std_dev) * ann_factor
380    } else {
381        0.0
382    };
383
384    let sortino_ratio = if downside_dev > 1e-12 {
385        ((mean - rf_per_period) / downside_dev) * ann_factor
386    } else {
387        0.0
388    };
389
390    Some(ReturnMetrics {
391        mean_return: mean,
392        annualized_return,
393        annualized_volatility,
394        sharpe_ratio,
395        sortino_ratio,
396        sample_count: returns.len(),
397    })
398}
399
400/// Cashflow-adjusted period return (Modified Dietz convention).
401///
402/// - `start_equity`: equity at beginning of period.
403/// - `end_equity`: equity at end of period.
404/// - `net_cashflow`: external deposits minus withdrawals occurring during the period.
405/// - `cashflow_weight`: time-weight fraction (0.0 = end of period, 0.5 = midpoint, 1.0 = start).
406///
407/// Ensures external deposits or withdrawals create exactly 0% return in the absence of market moves.
408pub fn cashflow_adjusted_return(
409    start_equity: f64,
410    end_equity: f64,
411    net_cashflow: f64,
412    cashflow_weight: f64,
413) -> Option<f64> {
414    let pnl = end_equity - start_equity - net_cashflow;
415    let weighted_capital = start_equity + cashflow_weight * net_cashflow;
416    if weighted_capital <= 0.0 || !pnl.is_finite() {
417        None
418    } else {
419        Some(pnl / weighted_capital)
420    }
421}
422
423/// Historical non-parametric risk metrics: Value at Risk (VaR) and Expected Shortfall (CVaR).
424#[derive(Debug, Clone, Copy, PartialEq)]
425#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
426pub struct HistoricalRiskStats {
427    /// Confidence level (e.g. 0.95 for 95%).
428    pub confidence_level: f64,
429    /// Value at Risk as a positive fractional loss (e.g. 0.03 for 3% potential loss).
430    pub var: f64,
431    /// Expected Shortfall (Conditional VaR): mean loss in the worst `(1 - confidence_level)` quantile.
432    pub expected_shortfall: f64,
433    /// Number of observations in the historical return sample.
434    pub sample_count: usize,
435}
436
437/// Computes non-parametric historical Value at Risk and Expected Shortfall from a return series.
438///
439/// Returns positive fractions representing potential loss (e.g. 0.05 = 5% loss).
440pub fn historical_var_and_es(
441    returns: &[f64],
442    confidence_level: f64,
443) -> Option<HistoricalRiskStats> {
444    if returns.is_empty() || confidence_level <= 0.0 || confidence_level >= 1.0 {
445        return None;
446    }
447
448    let mut sorted = returns.to_vec();
449    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
450
451    let n = sorted.len();
452    let p = 1.0 - confidence_level;
453    // Tail cutoff index (at least 1 item in tail)
454    let tail_count = ((p * n as f64).ceil() as usize).clamp(1, n);
455
456    let tail_slice = &sorted[..tail_count];
457    // VaR is the threshold loss at the boundary of the tail
458    let boundary_return = tail_slice.last().copied().unwrap_or(0.0);
459    let var = (-boundary_return).max(0.0);
460
461    // Expected shortfall is the average loss of the tail observations
462    let sum_tail_losses: f64 = tail_slice.iter().map(|&r| (-r).max(0.0)).sum();
463    let expected_shortfall = sum_tail_losses / tail_count as f64;
464
465    Some(HistoricalRiskStats {
466        confidence_level,
467        var,
468        expected_shortfall,
469        sample_count: n,
470    })
471}
472
473/// Pure calculation of a suggested exposure scaling factor for volatility targeting.
474///
475/// `scale = min(target_vol / current_vol, max_leverage)`
476pub fn volatility_targeting_scale(current_vol: f64, target_vol: f64, max_leverage: f64) -> f64 {
477    if !current_vol.is_finite()
478        || current_vol <= 0.0
479        || !target_vol.is_finite()
480        || target_vol <= 0.0
481    {
482        return 1.0;
483    }
484    let raw_scale = target_vol / current_vol;
485    let cap = if max_leverage.is_finite() && max_leverage > 0.0 {
486        max_leverage
487    } else {
488        1.0
489    };
490    raw_scale.min(cap).max(0.0)
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use crate::contract::InstrumentType;
497
498    #[test]
499    fn test_opposing_positions_net_zero_gross_positive() {
500        let ledger = CashLedger {
501            cash: 100_000.0,
502            ..Default::default()
503        };
504        let spec = ContractSpec {
505            multiplier: 25.0,
506            instrument_type: InstrumentType::LinearFuture,
507            ..Default::default()
508        };
509
510        // Long 1 contract at 20,000 pts (notional 500k)
511        let long_pos = PositionSnapshot {
512            symbol: "FDAX".to_string(),
513            spec: spec.clone(),
514            side: PositionSide::Long,
515            quantity: 1.0,
516            entry_price: 20_000.0,
517            current_price: 20_000.0,
518            stop_price: Some(19_980.0),
519            fx_to_account: 1.0,
520        };
521
522        // Short 1 contract at 20,000 pts (notional 500k)
523        let short_pos = PositionSnapshot {
524            symbol: "FDAX".to_string(),
525            spec,
526            side: PositionSide::Short,
527            quantity: 1.0,
528            entry_price: 20_000.0,
529            current_price: 20_000.0,
530            stop_price: Some(20_020.0),
531            fx_to_account: 1.0,
532        };
533
534        let snapshot =
535            evaluate_portfolio(Currency::eur(), &ledger, &[long_pos, short_pos]).unwrap();
536
537        assert_eq!(snapshot.long_notional, 500_000.0);
538        assert_eq!(snapshot.short_notional, 500_000.0);
539        assert_eq!(snapshot.gross_exposure, 1_000_000.0);
540        assert_eq!(snapshot.net_exposure, 0.0);
541        assert_eq!(snapshot.gross_leverage, 10.0);
542        assert_eq!(snapshot.net_leverage, 0.0);
543        assert_eq!(snapshot.equity, 100_000.0);
544        // Total stop risk: 20 * 25 * 1.0 + 20 * 25 * 1.0 = 500 + 500 = 1000 EUR
545        assert_eq!(snapshot.total_stop_risk, 1000.0);
546    }
547
548    #[test]
549    fn test_cashflow_neutrality() {
550        // Deposit of 50k on 100k starting capital with 0 market P&L yields 0.0% return
551        let r = cashflow_adjusted_return(100_000.0, 150_000.0, 50_000.0, 1.0).unwrap();
552        assert_eq!(r, 0.0);
553    }
554}