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 !mar.is_finite() {
68            return Err(Error::InvalidParameter {
69                message: "mar must be finite",
70            });
71        }
72        Ok(Self {
73            period,
74            mar,
75            window: VecDeque::with_capacity(period),
76            sum_upside: 0.0,
77            sum_downside_sq: 0.0,
78        })
79    }
80
81    /// Configured window of returns.
82    pub const fn period(&self) -> usize {
83        self.period
84    }
85
86    /// Configured minimal acceptable return.
87    pub const fn mar(&self) -> f64 {
88        self.mar
89    }
90}
91
92impl Indicator for UpsidePotentialRatio {
93    type Input = f64;
94    type Output = f64;
95
96    fn update(&mut self, ret: f64) -> Option<f64> {
97        if !ret.is_finite() {
98            return None;
99        }
100        if self.window.len() == self.period {
101            let old = self.window.pop_front().expect("non-empty");
102            let excess = old - self.mar;
103            self.sum_upside -= excess.max(0.0);
104            self.sum_downside_sq -= excess.min(0.0).powi(2);
105        }
106        let excess = ret - self.mar;
107        self.sum_upside += excess.max(0.0);
108        self.sum_downside_sq += excess.min(0.0).powi(2);
109        self.window.push_back(ret);
110        if self.window.len() < self.period {
111            return None;
112        }
113        let n = self.period as f64;
114        let upside_mean = self.sum_upside / n;
115        let downside_dev = (self.sum_downside_sq / n).sqrt();
116        if downside_dev > 0.0 {
117            Some(upside_mean / downside_dev)
118        } else {
119            Some(0.0)
120        }
121    }
122
123    fn reset(&mut self) {
124        self.window.clear();
125        self.sum_upside = 0.0;
126        self.sum_downside_sq = 0.0;
127    }
128
129    fn warmup_period(&self) -> usize {
130        self.period
131    }
132
133    fn is_ready(&self) -> bool {
134        self.window.len() == self.period
135    }
136
137    fn name(&self) -> &'static str {
138        "UpsidePotentialRatio"
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::traits::BatchExt;
146    use approx::assert_relative_eq;
147
148    #[test]
149    fn rejects_period_less_than_two() {
150        assert!(matches!(
151            UpsidePotentialRatio::new(1, 0.0),
152            Err(Error::InvalidPeriod { .. })
153        ));
154    }
155
156    #[test]
157    fn rejects_non_finite_mar() {
158        assert!(matches!(
159            UpsidePotentialRatio::new(10, f64::NAN),
160            Err(Error::InvalidParameter { .. })
161        ));
162    }
163
164    #[test]
165    fn accessors_and_metadata() {
166        let upr = UpsidePotentialRatio::new(20, 0.001).unwrap();
167        assert_eq!(upr.period(), 20);
168        assert_relative_eq!(upr.mar(), 0.001, epsilon = 1e-12);
169        assert_eq!(upr.warmup_period(), 20);
170        assert_eq!(upr.name(), "UpsidePotentialRatio");
171    }
172
173    #[test]
174    fn reference_value() {
175        // returns [0.02, -0.01, 0.03, -0.02], mar = 0.
176        // upside = (0.02 + 0 + 0.03 + 0)/4 = 0.0125.
177        // downside = sqrt((0 + 0.0001 + 0 + 0.0004)/4) = sqrt(0.000125).
178        // UPR = 0.0125 / sqrt(0.000125).
179        let mut upr = UpsidePotentialRatio::new(4, 0.0).unwrap();
180        let out = upr.batch(&[0.02, -0.01, 0.03, -0.02]);
181        let expected = 0.0125_f64 / (0.000_125_f64).sqrt();
182        assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
183    }
184
185    #[test]
186    fn no_downside_is_zero() {
187        let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
188        let last = upr
189            .batch(&[0.01, 0.02, 0.03])
190            .into_iter()
191            .flatten()
192            .last()
193            .unwrap();
194        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
195    }
196
197    #[test]
198    fn ignores_non_finite_input() {
199        let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
200        assert_eq!(upr.update(0.01), None);
201        assert_eq!(upr.update(f64::INFINITY), None);
202        assert_eq!(upr.update(-0.02), None);
203        assert!(upr.update(0.03).is_some());
204    }
205
206    #[test]
207    fn reset_clears_state() {
208        let mut upr = UpsidePotentialRatio::new(2, 0.0).unwrap();
209        upr.batch(&[0.02, -0.01]);
210        assert!(upr.is_ready());
211        upr.reset();
212        assert!(!upr.is_ready());
213        assert_eq!(upr.update(0.01), None);
214    }
215
216    #[test]
217    fn batch_equals_streaming() {
218        let rets: Vec<f64> = (0..60)
219            .map(|i| (f64::from(i) * 0.25).sin() * 0.02)
220            .collect();
221        let batch = UpsidePotentialRatio::new(12, 0.0).unwrap().batch(&rets);
222        let mut streamer = UpsidePotentialRatio::new(12, 0.0).unwrap();
223        let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
224        assert_eq!(batch, streamed);
225    }
226}