1use std::fmt;
8
9#[cfg(feature = "serde")]
10use serde::{Deserialize, Serialize};
11
12use crate::contract::{ContractSpec, Currency, ValuationError};
13
14#[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#[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 pub quantity: f64,
35 pub entry_price: f64,
36 pub current_price: f64,
37 pub stop_price: Option<f64>,
39 pub fx_to_account: f64,
41}
42
43#[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 pub stop_risk: Option<f64>,
54}
55
56#[derive(Debug, Clone, PartialEq)]
58#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
59pub struct CashLedger {
60 pub cash: f64,
62 pub cumulative_deposits: f64,
64 pub cumulative_withdrawals: f64,
66 pub cumulative_fees: f64,
68 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#[derive(Debug, Clone, PartialEq)]
86#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
87pub struct PortfolioSnapshot {
88 pub account_currency: Currency,
89 pub equity: f64,
91 pub cash: f64,
92 pub unrealized_pnl: f64,
93 pub cumulative_realized_pnl: f64,
94 pub cumulative_fees: f64,
95 pub net_deposits: f64,
97 pub long_notional: f64,
99 pub short_notional: f64,
101 pub gross_exposure: f64,
103 pub net_exposure: f64,
105 pub gross_leverage: f64,
107 pub net_leverage: f64,
109 pub total_stop_risk: f64,
112 pub max_position_concentration: f64,
114 pub positions: Vec<PositionEvaluation>,
116}
117
118#[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
142pub 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#[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
276pub 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#[derive(Debug, Clone, Copy, PartialEq)]
325#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
326pub struct ReturnMetrics {
327 pub mean_return: f64,
329 pub annualized_return: f64,
331 pub annualized_volatility: f64,
333 pub sharpe_ratio: f64,
335 pub sortino_ratio: f64,
337 pub sample_count: usize,
339}
340
341pub 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 let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
361 let std_dev = variance.sqrt();
362
363 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
400pub 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#[derive(Debug, Clone, Copy, PartialEq)]
425#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
426pub struct HistoricalRiskStats {
427 pub confidence_level: f64,
429 pub var: f64,
431 pub expected_shortfall: f64,
433 pub sample_count: usize,
435}
436
437pub 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 let tail_count = ((p * n as f64).ceil() as usize).clamp(1, n);
455
456 let tail_slice = &sorted[..tail_count];
457 let boundary_return = tail_slice.last().copied().unwrap_or(0.0);
459 let var = (-boundary_return).max(0.0);
460
461 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
473pub 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 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 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 assert_eq!(snapshot.total_stop_risk, 1000.0);
546 }
547
548 #[test]
549 fn test_cashflow_neutrality() {
550 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}