Skip to main content

wickra_core/indicators/
upside_potential_ratio.rs

1//! Upside Potential Ratio (Sortino, van der Meer & Plantinga) — upside mean over downside deviation.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Upside Potential Ratio over a trailing window of `period` returns, measured
9/// relative to a minimal acceptable return (`mar`).
10///
11/// ```text
12/// upside     = mean( max(r − mar, 0) )            over the window
13/// downside   = sqrt( mean( min(r − mar, 0)² ) )   over the window
14/// UPR        = upside / downside
15/// ```
16///
17/// Where the [`SharpeRatio`](crate::SharpeRatio) divides excess return by *total*
18/// volatility (penalising upside and downside symmetrically), the Upside Potential
19/// Ratio rewards only the average outperformance above the threshold while
20/// penalising solely the downside deviation below it. It is the purest expression
21/// of the Sortino philosophy: investors do not dislike upside variance, only
22/// shortfall risk.
23///
24/// `mar` (minimal acceptable return) is the per-period hurdle the caller supplies
25/// (e.g. `0.0` for break-even, or a target rate matching the return frequency). A
26/// window that never breaches the threshold has zero downside deviation; the
27/// indicator then reports `0.0` rather than dividing by zero.
28///
29/// Each `update` is O(1) — running sums maintain the upside total and the
30/// downside sum-of-squares as the window slides.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Indicator, UpsidePotentialRatio};
36///
37/// let mut indicator = UpsidePotentialRatio::new(20, 0.0).unwrap();
38/// let mut last = None;
39/// for i in 0..40 {
40///     last = indicator.update((f64::from(i) * 0.3).sin() * 0.02);
41/// }
42/// assert!(last.is_some());
43/// ```
44#[derive(Debug, Clone)]
45pub struct UpsidePotentialRatio {
46    period: usize,
47    mar: f64,
48    window: VecDeque<f64>,
49    sum_upside: f64,
50    sum_downside_sq: f64,
51}
52
53impl UpsidePotentialRatio {
54    /// Construct an Upside Potential Ratio over `period` returns with minimal
55    /// acceptable return `mar`.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`Error::InvalidPeriod`] if `period < 2`, or
60    /// [`Error::InvalidParameter`] if `mar` is not finite.
61    pub fn new(period: usize, mar: f64) -> Result<Self> {
62        if period < 2 {
63            return Err(Error::InvalidPeriod {
64                message: "upside potential ratio needs period >= 2",
65            });
66        }
67        if period > crate::error::MAX_PERIOD {
68            return Err(Error::InvalidPeriod {
69                message: crate::error::PERIOD_ABOVE_MAX,
70            });
71        }
72        if !mar.is_finite() {
73            return Err(Error::InvalidParameter {
74                message: "mar must be finite",
75            });
76        }
77        Ok(Self {
78            period,
79            mar,
80            window: VecDeque::with_capacity(period),
81            sum_upside: 0.0,
82            sum_downside_sq: 0.0,
83        })
84    }
85
86    /// Configured window of returns.
87    pub const fn period(&self) -> usize {
88        self.period
89    }
90
91    /// Configured minimal acceptable return.
92    pub const fn mar(&self) -> f64 {
93        self.mar
94    }
95}
96
97impl Indicator for UpsidePotentialRatio {
98    type Input = f64;
99    type Output = f64;
100
101    #[inline]
102    fn update(&mut self, ret: f64) -> Option<f64> {
103        if !ret.is_finite() {
104            return None;
105        }
106        if self.window.len() == self.period {
107            let old = self.window.pop_front().expect("non-empty");
108            let excess = old - self.mar;
109            self.sum_upside -= excess.max(0.0);
110            self.sum_downside_sq -= excess.min(0.0).powi(2);
111        }
112        let excess = ret - self.mar;
113        self.sum_upside += excess.max(0.0);
114        self.sum_downside_sq += excess.min(0.0).powi(2);
115        self.window.push_back(ret);
116        if self.window.len() < self.period {
117            return None;
118        }
119        let n = self.period as f64;
120        let upside_mean = self.sum_upside / n;
121        let downside_dev = (self.sum_downside_sq / n).sqrt();
122        if downside_dev > 0.0 {
123            Some(upside_mean / downside_dev)
124        } else {
125            Some(0.0)
126        }
127    }
128
129    fn reset(&mut self) {
130        self.window.clear();
131        self.sum_upside = 0.0;
132        self.sum_downside_sq = 0.0;
133    }
134
135    #[inline]
136    fn warmup_period(&self) -> usize {
137        self.period
138    }
139
140    #[inline]
141    fn is_ready(&self) -> bool {
142        self.window.len() == self.period
143    }
144
145    #[inline]
146    fn name(&self) -> &'static str {
147        "UpsidePotentialRatio"
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::traits::BatchExt;
155    use approx::assert_relative_eq;
156
157    #[test]
158    fn rejects_period_less_than_two() {
159        assert!(matches!(
160            UpsidePotentialRatio::new(1, 0.0),
161            Err(Error::InvalidPeriod { .. })
162        ));
163    }
164
165    #[test]
166    fn rejects_non_finite_mar() {
167        assert!(matches!(
168            UpsidePotentialRatio::new(10, f64::NAN),
169            Err(Error::InvalidParameter { .. })
170        ));
171    }
172
173    #[test]
174    fn accessors_and_metadata() {
175        let upr = UpsidePotentialRatio::new(20, 0.001).unwrap();
176        assert_eq!(upr.period(), 20);
177        assert_relative_eq!(upr.mar(), 0.001, epsilon = 1e-12);
178        assert_eq!(upr.warmup_period(), 20);
179        assert_eq!(upr.name(), "UpsidePotentialRatio");
180    }
181
182    #[test]
183    fn reference_value() {
184        // returns [0.02, -0.01, 0.03, -0.02], mar = 0.
185        // upside = (0.02 + 0 + 0.03 + 0)/4 = 0.0125.
186        // downside = sqrt((0 + 0.0001 + 0 + 0.0004)/4) = sqrt(0.000125).
187        // UPR = 0.0125 / sqrt(0.000125).
188        let mut upr = UpsidePotentialRatio::new(4, 0.0).unwrap();
189        let out = upr.batch(&[0.02, -0.01, 0.03, -0.02]);
190        let expected = 0.0125_f64 / (0.000_125_f64).sqrt();
191        assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
192    }
193
194    #[test]
195    fn no_downside_is_zero() {
196        let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
197        let last = upr
198            .batch(&[0.01, 0.02, 0.03])
199            .into_iter()
200            .flatten()
201            .last()
202            .unwrap();
203        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
204    }
205
206    #[test]
207    fn ignores_non_finite_input() {
208        let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
209        assert_eq!(upr.update(0.01), None);
210        assert_eq!(upr.update(f64::INFINITY), None);
211        assert_eq!(upr.update(-0.02), None);
212        assert!(upr.update(0.03).is_some());
213    }
214
215    #[test]
216    fn reset_clears_state() {
217        let mut upr = UpsidePotentialRatio::new(2, 0.0).unwrap();
218        upr.batch(&[0.02, -0.01]);
219        assert!(upr.is_ready());
220        upr.reset();
221        assert!(!upr.is_ready());
222        assert_eq!(upr.update(0.01), None);
223    }
224
225    #[test]
226    fn batch_equals_streaming() {
227        let rets: Vec<f64> = (0..60)
228            .map(|i| (f64::from(i) * 0.25).sin() * 0.02)
229            .collect();
230        let batch = UpsidePotentialRatio::new(12, 0.0).unwrap().batch(&rets);
231        let mut streamer = UpsidePotentialRatio::new(12, 0.0).unwrap();
232        let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
233        assert_eq!(batch, streamed);
234    }
235}