Skip to main content

finance_solution/stocks/
path.rs

1//! Price-path analysis: solution struct, period series, and pretty tables.
2//!
3//! # Conventions
4//!
5//! - Prices must be **strictly positive** for log returns, CAGR, and drawdowns.
6//! - Volatility uses **sample** standard deviation (`n − 1`).
7//! - Annualization: pass `periods_per_year` explicitly (e.g. `252.0` daily, `12.0` monthly).
8//! - Sharpe / Sortino: excess return and volatility share the **same** period units.
9//! - Max drawdown is a **positive fraction** (0.25 = 25% peak-to-trough).
10use 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/// Kind of return used for mean / vol / Sharpe on the path.
23#[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/// Options for [`price_path_solution`].
40#[derive(Clone, Copy, Debug)]
41pub struct PricePathOptions {
42    /// Periods per year for annualizing volatility (e.g. 252, 12, 1).
43    pub periods_per_year: f64,
44    /// Calendar years spanned by the full path (for CAGR). If `None`, CAGR uses
45    /// `(prices.len() - 1) / periods_per_year`.
46    pub years: Option<f64>,
47    /// Per-period risk-free rate for Sharpe (same units as period returns).
48    pub risk_free_rate: f64,
49    /// Target / MAR for Sortino (default 0.0).
50    pub sortino_target: f64,
51    /// Which return series drives mean/vol/Sharpe.
52    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/// Full analysis of an ordered price path.
97///
98/// Create with [`price_path_solution`].
99///
100/// # Examples
101/// ```
102/// use finance_solution::*;
103///
104/// let prices = [100.0, 110.0, 105.0, 120.0];
105/// let opts = PricePathOptions::new(12.0).with_years(3.0 / 12.0);
106/// let path = price_path_solution(&prices, opts).unwrap();
107///
108/// assert!(path.total_return() > 0.0);
109/// assert_approx_equal!(path.max_drawdown(), max_drawdown(&prices).unwrap());
110///
111/// let series = path.series();
112/// assert_eq!(series.len(), prices.len() - 1);
113/// series.print_table();
114/// ```
115///
116/// Sample `series().print_table()` (default formatting; columns match the live table):
117///
118/// ```text
119/// period  price_start  price_end  simple_return  log_return  wealth_index  drawdown  roll_max_dd
120/// ------  -----------  ---------  -------------  ----------  ------------  --------  -----------
121///      1     100.0000   110.0000       0.100000    0.095310        1.1000  0.000000     0.000000
122///      2     110.0000   105.0000      -0.045455   -0.046520        1.0500  0.045455     0.045455
123///      3     105.0000   120.0000       0.142857    0.133531        1.2000  0.000000     0.045455
124/// ```
125///
126/// Paths with only two prices still work for total return / CAGR / drawdown; sample
127/// volatility / Sharpe / Sortino are `None` until there are at least two period returns
128/// (three prices) and, for Sortino, at least one return below the target.
129#[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    /// Years used for CAGR.
160    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    /// `None` if fewer than one return (should not happen for valid paths).
170    pub fn mean_return(&self) -> Option<f64> {
171        self.mean_return
172    }
173    /// `None` if fewer than two returns (sample vol undefined).
174    pub fn volatility(&self) -> Option<f64> {
175        self.volatility
176    }
177    /// `None` if sample volatility is undefined.
178    pub fn volatility_annualized(&self) -> Option<f64> {
179        self.volatility_annualized
180    }
181    /// `None` if volatility undefined or zero.
182    pub fn sharpe_ratio(&self) -> Option<f64> {
183        self.sharpe_ratio
184    }
185    /// `None` if no downside observations vs target (or vol path too short).
186    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    /// Simple returns along the path.
200    pub fn simple_returns(&self) -> FinanceResult<Vec<f64>> {
201        simple_returns(&self.prices)
202    }
203
204    /// Log returns along the path.
205    pub fn log_returns(&self) -> FinanceResult<Vec<f64>> {
206        log_returns(&self.prices)
207    }
208
209    /// Period-by-period detail (length `n_prices - 1`).
210    pub fn series(&self) -> PricePathSeries {
211        // Internal invariant: solution construction already validated all prices.
212        build_series(&self.prices).expect("validated PricePathSolution prices")
213    }
214
215    /// Summary metrics as a small table (not the period series).
216    ///
217    /// Optional stats (vol / Sharpe / Sortino) print as `n/a` when undefined — e.g. fewer
218    /// than two period returns, or no downside observations for Sortino.
219    ///
220    /// # Examples
221    /// ```
222    /// use finance_solution::*;
223    ///
224    /// let path = price_path_solution(&[100.0, 110.0, 105.0], PricePathOptions::default()).unwrap();
225    /// path.print_summary();
226    ///
227    /// // Two prices: total return works; sample vol is n/a (only one return).
228    /// let short = price_path_solution(&[100.0, 110.0], PricePathOptions::default()).unwrap();
229    /// assert!(short.volatility().is_none());
230    /// short.print_summary(); // must not panic
231    /// ```
232    ///
233    /// Sample output (three+ prices, with vol):
234    ///
235    /// ```text
236    ///         metric    value
237    /// --------------  -------
238    ///       n_prices       10
239    ///          years   0.8333
240    ///   total_return   0.2500
241    ///           cagr   0.3070
242    ///    mean_return   0.0261
243    ///     volatility   0.0477
244    /// volatility_ann   0.1653
245    ///         sharpe   0.5469
246    ///        sortino   1.7235
247    ///   max_drawdown   0.0278
248    /// ```
249    pub fn print_summary(&self) {
250        self.print_summary_locale_opt(None, None);
251    }
252
253    /// Locale-aware [`print_summary`](Self::print_summary).
254    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        // Value column is type "s" so optional metrics can show "n/a" without parse panics.
264        // Numeric cells are pre-formatted when a locale/precision is requested.
265        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    /// Alias: print period series table with running wealth/drawdown columns.
291    pub fn print_table(&self) {
292        self.series().print_table();
293    }
294}
295
296/// One step between consecutive prices.
297#[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    /// Cumulative wealth of $1 invested at the start of the path, after this step.
305    wealth_index: f64,
306    /// Drawdown at `price_end` vs running peak along the full path.
307    drawdown: f64,
308    /// Running maximum drawdown from the start through `price_end`.
309    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/// Period series for a price path. Derefs to `[PricePathPeriod]`.
348#[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
409/// Build a [`PricePathSolution`] summarizing returns, risk, and period detail for a price path.
410///
411/// # Examples
412/// ```
413/// use finance_solution::{price_path_solution, PricePathOptions, FinanceError};
414///
415/// match price_path_solution(&[100.0, 110.0], PricePathOptions::default()) {
416///     Ok(path) => assert!(path.total_return() > 0.0),
417///     Err(FinanceError::Unsolvable { message }) => panic!("{message}"),
418///     Err(e) => panic!("{e}"),
419/// }
420///
421/// assert!(matches!(
422///     price_path_solution(&[100.0], PricePathOptions::default()),
423///     Err(FinanceError::Unsolvable { .. })
424/// ));
425/// ```
426pub 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        // Peak 110 → 105 is the only drawdown.
554        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}