1use std::ops::Deref;
11
12use crate::stocks::returns::{
13 cagr, log_return, log_returns, mean_return, simple_return, simple_returns, total_return,
14};
15use crate::stocks::risk::{
16 drawdown_series, rolling_max_drawdown, sharpe_ratio, sortino_ratio, volatility,
17 volatility_annualized,
18};
19use crate::util::error::{require_finite, FinanceError, FinanceResult};
20use crate::{columns_with_strings, print_table_locale_opt};
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
24pub enum ReturnKind {
25 #[default]
26 Simple,
27 Log,
28}
29
30impl std::fmt::Display for ReturnKind {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 match self {
33 ReturnKind::Simple => write!(f, "Simple"),
34 ReturnKind::Log => write!(f, "Log"),
35 }
36 }
37}
38
39#[derive(Clone, Copy, Debug)]
41pub struct PricePathOptions {
42 pub periods_per_year: f64,
44 pub years: Option<f64>,
47 pub risk_free_rate: f64,
49 pub sortino_target: f64,
51 pub return_kind: ReturnKind,
53}
54
55impl Default for PricePathOptions {
56 fn default() -> Self {
57 Self {
58 periods_per_year: 252.0,
59 years: None,
60 risk_free_rate: 0.0,
61 sortino_target: 0.0,
62 return_kind: ReturnKind::Simple,
63 }
64 }
65}
66
67impl PricePathOptions {
68 pub fn new(periods_per_year: f64) -> Self {
69 Self {
70 periods_per_year,
71 ..Default::default()
72 }
73 }
74
75 pub fn with_years(mut self, years: f64) -> Self {
76 self.years = Some(years);
77 self
78 }
79
80 pub fn with_risk_free(mut self, risk_free_rate: f64) -> Self {
81 self.risk_free_rate = risk_free_rate;
82 self
83 }
84
85 pub fn with_sortino_target(mut self, target: f64) -> Self {
86 self.sortino_target = target;
87 self
88 }
89
90 pub fn with_return_kind(mut self, kind: ReturnKind) -> Self {
91 self.return_kind = kind;
92 self
93 }
94}
95
96#[derive(Clone, Debug)]
130pub struct PricePathSolution {
131 prices: Vec<f64>,
132 options: PricePathOptions,
133 years: f64,
134 total_return: f64,
135 cagr: f64,
136 mean_return: Option<f64>,
137 volatility: Option<f64>,
138 volatility_annualized: Option<f64>,
139 sharpe_ratio: Option<f64>,
140 sortino_ratio: Option<f64>,
141 max_drawdown: f64,
142 formula: String,
143 symbolic_formula: String,
144}
145
146impl PricePathSolution {
147 pub fn prices(&self) -> &[f64] {
148 &self.prices
149 }
150 pub fn options(&self) -> &PricePathOptions {
151 &self.options
152 }
153 pub fn n_prices(&self) -> usize {
154 self.prices.len()
155 }
156 pub fn n_returns(&self) -> usize {
157 self.prices.len().saturating_sub(1)
158 }
159 pub fn years(&self) -> f64 {
161 self.years
162 }
163 pub fn total_return(&self) -> f64 {
164 self.total_return
165 }
166 pub fn cagr(&self) -> f64 {
167 self.cagr
168 }
169 pub fn mean_return(&self) -> Option<f64> {
171 self.mean_return
172 }
173 pub fn volatility(&self) -> Option<f64> {
175 self.volatility
176 }
177 pub fn volatility_annualized(&self) -> Option<f64> {
179 self.volatility_annualized
180 }
181 pub fn sharpe_ratio(&self) -> Option<f64> {
183 self.sharpe_ratio
184 }
185 pub fn sortino_ratio(&self) -> Option<f64> {
187 self.sortino_ratio
188 }
189 pub fn max_drawdown(&self) -> f64 {
190 self.max_drawdown
191 }
192 pub fn formula(&self) -> &str {
193 &self.formula
194 }
195 pub fn symbolic_formula(&self) -> &str {
196 &self.symbolic_formula
197 }
198
199 pub fn simple_returns(&self) -> FinanceResult<Vec<f64>> {
201 simple_returns(&self.prices)
202 }
203
204 pub fn log_returns(&self) -> FinanceResult<Vec<f64>> {
206 log_returns(&self.prices)
207 }
208
209 pub fn series(&self) -> PricePathSeries {
211 build_series(&self.prices).expect("validated PricePathSolution prices")
213 }
214
215 pub fn print_summary(&self) {
250 self.print_summary_locale_opt(None, None);
251 }
252
253 pub fn print_summary_locale(&self, locale: &num_format::Locale, precision: usize) {
255 self.print_summary_locale_opt(Some(locale), Some(precision));
256 }
257
258 fn print_summary_locale_opt(
259 &self,
260 locale: Option<&num_format::Locale>,
261 precision: Option<usize>,
262 ) {
263 let columns = columns_with_strings(&[("metric", "s", true), ("value", "s", true)]);
266 let fmt_num = |v: f64| -> String {
267 match (locale, precision) {
268 (Some(loc), Some(prec)) => crate::format_float_locale_opt(v, Some(loc), Some(prec)),
269 (Some(loc), None) => crate::format_float_locale_opt(v, Some(loc), Some(4)),
270 (None, Some(prec)) => crate::format_float_locale_opt(v, None, Some(prec)),
271 (None, None) => crate::format_float_locale_opt(v, None, Some(4)),
272 }
273 };
274 let opt_num = |v: Option<f64>| v.map(fmt_num).unwrap_or_else(|| "n/a".into());
275 let data = vec![
276 vec!["n_prices".into(), self.prices.len().to_string()],
277 vec!["years".into(), fmt_num(self.years)],
278 vec!["total_return".into(), fmt_num(self.total_return)],
279 vec!["cagr".into(), fmt_num(self.cagr)],
280 vec!["mean_return".into(), opt_num(self.mean_return)],
281 vec!["volatility".into(), opt_num(self.volatility)],
282 vec!["volatility_ann".into(), opt_num(self.volatility_annualized)],
283 vec!["sharpe".into(), opt_num(self.sharpe_ratio)],
284 vec!["sortino".into(), opt_num(self.sortino_ratio)],
285 vec!["max_drawdown".into(), fmt_num(self.max_drawdown)],
286 ];
287 print_table_locale_opt(&columns, data, locale, precision);
288 }
289
290 pub fn print_table(&self) {
292 self.series().print_table();
293 }
294}
295
296#[derive(Clone, Debug)]
298pub struct PricePathPeriod {
299 period: u32,
300 price_start: f64,
301 price_end: f64,
302 simple_return: f64,
303 log_return: f64,
304 wealth_index: f64,
306 drawdown: f64,
308 rolling_max_drawdown: f64,
310 formula: String,
311 symbolic_formula: String,
312}
313
314impl PricePathPeriod {
315 pub fn period(&self) -> u32 {
316 self.period
317 }
318 pub fn price_start(&self) -> f64 {
319 self.price_start
320 }
321 pub fn price_end(&self) -> f64 {
322 self.price_end
323 }
324 pub fn simple_return(&self) -> f64 {
325 self.simple_return
326 }
327 pub fn log_return(&self) -> f64 {
328 self.log_return
329 }
330 pub fn wealth_index(&self) -> f64 {
331 self.wealth_index
332 }
333 pub fn drawdown(&self) -> f64 {
334 self.drawdown
335 }
336 pub fn rolling_max_drawdown(&self) -> f64 {
337 self.rolling_max_drawdown
338 }
339 pub fn formula(&self) -> &str {
340 &self.formula
341 }
342 pub fn symbolic_formula(&self) -> &str {
343 &self.symbolic_formula
344 }
345}
346
347#[derive(Clone, Debug)]
349pub struct PricePathSeries(Vec<PricePathPeriod>);
350
351impl PricePathSeries {
352 pub fn filter<P>(&self, predicate: P) -> Self
353 where
354 P: Fn(&&PricePathPeriod) -> bool,
355 {
356 Self(self.iter().filter(|x| predicate(x)).cloned().collect())
357 }
358
359 pub fn print_table(&self) {
360 self.print_table_locale_opt(None, None);
361 }
362
363 pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
364 self.print_table_locale_opt(Some(locale), Some(precision));
365 }
366
367 fn print_table_locale_opt(
368 &self,
369 locale: Option<&num_format::Locale>,
370 precision: Option<usize>,
371 ) {
372 let columns = columns_with_strings(&[
373 ("period", "i", true),
374 ("price_start", "f", true),
375 ("price_end", "f", true),
376 ("simple_return", "r", true),
377 ("log_return", "r", true),
378 ("wealth_index", "f", true),
379 ("drawdown", "r", true),
380 ("roll_max_dd", "r", true),
381 ]);
382 let data = self
383 .iter()
384 .map(|e| {
385 vec![
386 e.period.to_string(),
387 e.price_start.to_string(),
388 e.price_end.to_string(),
389 e.simple_return.to_string(),
390 e.log_return.to_string(),
391 e.wealth_index.to_string(),
392 e.drawdown.to_string(),
393 e.rolling_max_drawdown.to_string(),
394 ]
395 })
396 .collect();
397 print_table_locale_opt(&columns, data, locale, precision);
398 }
399}
400
401impl Deref for PricePathSeries {
402 type Target = Vec<PricePathPeriod>;
403
404 fn deref(&self) -> &Self::Target {
405 &self.0
406 }
407}
408
409pub fn price_path_solution(
427 prices: &[f64],
428 options: PricePathOptions,
429) -> FinanceResult<PricePathSolution> {
430 require_finite("periods_per_year", options.periods_per_year)?;
431 if options.periods_per_year <= 0.0 {
432 return Err(FinanceError::Unsolvable {
433 message: "periods_per_year must be positive",
434 });
435 }
436 require_finite("risk_free_rate", options.risk_free_rate)?;
437 require_finite("sortino_target", options.sortino_target)?;
438
439 if prices.len() < 2 {
440 return Err(FinanceError::Unsolvable {
441 message: "price_path_solution requires at least two prices",
442 });
443 }
444 for &p in prices {
445 require_finite("prices", p)?;
446 if p <= 0.0 {
447 return Err(FinanceError::InvalidCashflow {
448 message: "price_path_solution requires strictly positive prices",
449 });
450 }
451 }
452
453 let start = prices[0];
454 let end = prices[prices.len() - 1];
455 let n_steps = (prices.len() - 1) as f64;
456 let years = match options.years {
457 Some(y) => {
458 require_finite("years", y)?;
459 if y <= 0.0 {
460 return Err(FinanceError::Unsolvable {
461 message: "years must be positive when provided",
462 });
463 }
464 y
465 }
466 None => n_steps / options.periods_per_year,
467 };
468
469 let total_return = total_return(start, end)?;
470 let cagr = cagr(start, end, years)?;
471
472 let simple = simple_returns(prices)?;
473 let log = log_returns(prices)?;
474 let series_for_stats = match options.return_kind {
475 ReturnKind::Simple => &simple[..],
476 ReturnKind::Log => &log[..],
477 };
478
479 let mean_return = mean_return(series_for_stats).ok();
480 let volatility = volatility(series_for_stats).ok();
481 let volatility_annualized = volatility
482 .and_then(|_| volatility_annualized(series_for_stats, options.periods_per_year).ok());
483 let sharpe_ratio = sharpe_ratio(series_for_stats, options.risk_free_rate).ok();
484 let sortino_ratio = sortino_ratio(series_for_stats, options.sortino_target).ok();
485 let max_drawdown = drawdown_series(prices)?.into_iter().fold(0.0_f64, f64::max);
486
487 let vol_s = volatility
488 .map(|v| format!("{v:.6}"))
489 .unwrap_or_else(|| "n/a".into());
490 let formula = format!(
491 "total_return {:.6} = ({:.4} - {:.4}) / {:.4}; cagr {:.6} over {:.4}y; vol {}; max_dd {:.6}",
492 total_return, end, start, start, cagr, years, vol_s, max_drawdown
493 );
494 let symbolic = "total_return = (P_n - P_0)/P_0; cagr = (P_n/P_0)^(1/years)-1; vol = sample_stdev(r); max_dd = max((peak-p)/peak)";
495
496 Ok(PricePathSolution {
497 prices: prices.to_vec(),
498 options,
499 years,
500 total_return,
501 cagr,
502 mean_return,
503 volatility,
504 volatility_annualized,
505 sharpe_ratio,
506 sortino_ratio,
507 max_drawdown,
508 formula,
509 symbolic_formula: symbolic.to_string(),
510 })
511}
512
513fn build_series(prices: &[f64]) -> FinanceResult<PricePathSeries> {
514 let drawdowns = drawdown_series(prices)?;
515 let rolling = rolling_max_drawdown(prices)?;
516 let mut rows = Vec::with_capacity(prices.len() - 1);
517 let mut wealth = 1.0_f64;
518
519 for i in 0..prices.len() - 1 {
520 let p0 = prices[i];
521 let p1 = prices[i + 1];
522 let sr = simple_return(p0, p1)?;
523 let lr = log_return(p0, p1)?;
524 wealth *= 1.0 + sr;
525 let formula = format!("{:.6} = ({:.4} - {:.4}) / {:.4}", sr, p1, p0, p0);
526 rows.push(PricePathPeriod {
527 period: (i + 1) as u32,
528 price_start: p0,
529 price_end: p1,
530 simple_return: sr,
531 log_return: lr,
532 wealth_index: wealth,
533 drawdown: drawdowns[i + 1],
534 rolling_max_drawdown: rolling[i + 1],
535 formula,
536 symbolic_formula: "r = (P_t - P_{t-1}) / P_{t-1}".to_string(),
537 });
538 }
539 Ok(PricePathSeries(rows))
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use crate::*;
546
547 #[test]
548 fn test_path_solution_basic() {
549 let prices = [100.0, 110.0, 105.0, 120.0];
550 let path =
551 price_path_solution(&prices, PricePathOptions::new(12.0).with_years(0.25)).unwrap();
552 assert_approx_equal!(path.total_return(), 0.20);
553 assert_approx_equal!(path.max_drawdown(), 5.0 / 110.0);
555
556 let series = path.series();
557 assert_eq!(series.len(), 3);
558 assert_approx_equal!(series[0].simple_return(), 0.10);
559 assert_approx_equal!(series[2].wealth_index(), 1.20);
560 assert!(!path.formula().is_empty());
561 }
562
563 #[test]
564 fn test_path_rejects_short_or_nonpositive() {
565 assert!(matches!(
566 price_path_solution(&[100.0], PricePathOptions::default()),
567 Err(FinanceError::Unsolvable { .. })
568 ));
569 assert!(matches!(
570 price_path_solution(&[100.0, -1.0], PricePathOptions::default()),
571 Err(FinanceError::InvalidCashflow { .. })
572 ));
573 }
574
575 #[test]
576 fn test_round_trip_wealth() {
577 let prices = [50.0, 55.0, 52.25, 60.0];
578 let path = price_path_solution(&prices, PricePathOptions::default()).unwrap();
579 let last_w = path.series().last().unwrap().wealth_index();
580 assert_approx_equal!(last_w, prices[prices.len() - 1] / prices[0]);
581 }
582}