Skip to main content

nautilus_analysis/statistics/
up_capture_ratio.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Up capture ratio statistic (benchmark-relative).
17
18use std::fmt::Display;
19
20use nautilus_model::position::Position;
21
22use crate::{Returns, statistic::PortfolioStatistic};
23
24/// Calculates the up capture ratio of portfolio returns relative to a benchmark.
25///
26/// The up capture ratio measures how the portfolio performed, on average, during the
27/// periods when the benchmark return was positive. It is the ratio of the portfolio's
28/// geometric annualized return to the benchmark's geometric annualized return, both
29/// computed over the up-market subset only:
30///
31/// `UpCapture = annualized_return(portfolio | benchmark > 0) / annualized_return(benchmark | benchmark > 0)`
32///
33/// where each side's annualized return is the geometric (CAGR-style) value
34/// `(prod(1 + x_i))^(period / m) - 1` and `m` is the number of up-market periods (the
35/// size of the filtered subset, not the full aligned length). The period defaults to
36/// 252 trading days. A value above 1.0 means the portfolio outperformed the benchmark
37/// in up markets.
38///
39/// This is the `empyrical.up_capture` convention (geometric annualized-return ratio over
40/// the `benchmark > 0` subset). Note that this differs from the Morningstar definition,
41/// which uses a ratio of *cumulative* (non-annualized) returns; the two coincide only
42/// when both subsets contain the same number of periods.
43///
44/// # References
45///
46/// - empyrical `up_capture` / `capture` / `annual_return`
47///   (<https://github.com/quantopian/empyrical>).
48/// - CFA Institute Investment Foundations, 3rd Edition
49#[repr(C)]
50#[derive(Debug, Clone)]
51#[cfg_attr(
52    feature = "python",
53    pyo3::pyclass(module = "nautilus_trader.analysis", from_py_object)
54)]
55#[cfg_attr(
56    feature = "python",
57    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
58)]
59pub struct UpCaptureRatio {
60    /// The annualization period (default: 252 for daily data).
61    period: usize,
62}
63
64impl UpCaptureRatio {
65    /// Creates a new [`UpCaptureRatio`] instance.
66    #[must_use]
67    pub fn new(period: Option<usize>) -> Self {
68        Self {
69            period: period.unwrap_or(252),
70        }
71    }
72}
73
74impl Display for UpCaptureRatio {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        write!(f, "Up Capture Ratio ({} days)", self.period)
77    }
78}
79
80impl PortfolioStatistic for UpCaptureRatio {
81    type Item = f64;
82
83    fn name(&self) -> String {
84        self.to_string()
85    }
86
87    fn calculate_from_returns(&self, _returns: &Returns) -> Option<Self::Item> {
88        None
89    }
90
91    fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
92        None
93    }
94
95    fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
96        None
97    }
98
99    fn calculate_from_returns_with_benchmark(
100        &self,
101        returns: &Returns,
102        benchmark: &Returns,
103    ) -> Option<Self::Item> {
104        let (r, b) = self.align_returns(returns, benchmark);
105        if r.len() < 2 {
106            return Some(f64::NAN);
107        }
108
109        Some(capture_ratio(&r, &b, self.period, MarketSide::Up))
110    }
111}
112
113/// The side of the market (sign of the benchmark return) to filter on.
114#[derive(Debug, Clone, Copy)]
115pub(crate) enum MarketSide {
116    /// Periods where the benchmark return is strictly positive.
117    Up,
118    /// Periods where the benchmark return is strictly negative.
119    Down,
120}
121
122/// Computes the geometric (CAGR-style) annualized return of `x`.
123///
124/// `annualized = (prod(1 + x_i))^(period / m) - 1`, where `m = x.len()`. This mirrors
125/// `empyrical.annual_return`, which annualizes by the number of periods in the slice
126/// passed to it. Returns `f64::NAN` for an empty slice.
127pub(crate) fn geometric_annualized_return(x: &[f64], period: usize) -> f64 {
128    let m = x.len();
129    if m == 0 {
130        return f64::NAN;
131    }
132    let growth = x.iter().map(|&xi| 1.0 + xi).product::<f64>();
133    growth.powf(period as f64 / m as f64) - 1.0
134}
135
136/// Computes the capture ratio of `r` against `b` on the requested market side.
137///
138/// Filters both series to the periods where the benchmark return matches `side`
139/// (`b_i > 0` for [`MarketSide::Up`], `b_i < 0` for [`MarketSide::Down`]), then returns
140/// the ratio of the portfolio's geometric annualized return to the benchmark's geometric
141/// annualized return over that subset, matching the `empyrical.up_capture` /
142/// `empyrical.down_capture` convention.
143///
144/// Returns `f64::NAN` when the filtered subset is empty (no qualifying periods) or when
145/// the benchmark's annualized return over the subset is within `f64::EPSILON` of zero
146/// (which would otherwise divide by zero). Callers must ensure `r.len() == b.len()`.
147pub(crate) fn capture_ratio(r: &[f64], b: &[f64], period: usize, side: MarketSide) -> f64 {
148    let mut r_sub = Vec::new();
149    let mut b_sub = Vec::new();
150
151    for (&ri, &bi) in r.iter().zip(b.iter()) {
152        let keep = match side {
153            MarketSide::Up => bi > 0.0,
154            MarketSide::Down => bi < 0.0,
155        };
156
157        if keep {
158            r_sub.push(ri);
159            b_sub.push(bi);
160        }
161    }
162
163    if r_sub.is_empty() {
164        return f64::NAN;
165    }
166
167    let annual_r = geometric_annualized_return(&r_sub, period);
168    let annual_b = geometric_annualized_return(&b_sub, period);
169    if annual_b.abs() < f64::EPSILON {
170        return f64::NAN;
171    }
172
173    annual_r / annual_b
174}
175
176#[cfg(test)]
177mod tests {
178    use std::collections::BTreeMap;
179
180    use nautilus_core::{UnixNanos, approx_eq};
181    use rstest::rstest;
182
183    use super::*;
184
185    fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
186        let mut new_return = BTreeMap::new();
187        let one_day_in_nanos = 86_400_000_000_000;
188        let start_time = 1_600_000_000_000_000_000;
189
190        for (i, &value) in values.iter().enumerate() {
191            let timestamp = start_time + i as u64 * one_day_in_nanos;
192            new_return.insert(UnixNanos::from(timestamp), value);
193        }
194
195        new_return
196    }
197
198    #[rstest]
199    fn test_name() {
200        let stat = UpCaptureRatio::new(None);
201        assert_eq!(stat.name(), "Up Capture Ratio (252 days)");
202    }
203
204    #[rstest]
205    fn test_name_non_default_period() {
206        let stat = UpCaptureRatio::new(Some(63));
207        assert_eq!(stat.name(), "Up Capture Ratio (63 days)");
208    }
209
210    #[rstest]
211    fn test_known_value() {
212        // Default period = 252. With
213        //   b = [0.01, -0.02, 0.015, -0.005, 0.025]
214        //   r = [0.02, -0.04, 0.030, -0.010, 0.050]
215        // the up subset (b > 0) is days 0,2,4: b_up = [0.01, 0.015, 0.025],
216        // r_up = [0.02, 0.030, 0.050], m = 3, period = 252.
217        //   annual_r = (1.02*1.03*1.05)^(252/3) - 1
218        //   annual_b = (1.01*1.015*1.025)^(252/3) - 1
219        //   up_capture = annual_r / annual_b
220        // Cross-validated against empyrical 0.5.5 up_capture (period='daily'): 60.31258720129805.
221        let benchmark = create_returns(&[0.01, -0.02, 0.015, -0.005, 0.025]);
222        let returns = create_returns(&[0.02, -0.04, 0.030, -0.010, 0.050]);
223        let stat = UpCaptureRatio::new(Some(252));
224        let result = stat
225            .calculate_from_returns_with_benchmark(&returns, &benchmark)
226            .unwrap();
227        assert!(approx_eq!(
228            f64,
229            result,
230            60.312_587_201_298_05,
231            epsilon = 1e-9
232        ));
233    }
234
235    #[rstest]
236    fn test_known_value_small_period() {
237        // Small period = 4 keeps the geometric annualization hand-checkable.
238        //   b = [0.01, -0.02, 0.015, 0.025], r = [0.02, -0.04, 0.030, 0.050]
239        // up subset (b > 0) is days 0,2,3: b_up = [0.01, 0.015, 0.025],
240        // r_up = [0.02, 0.030, 0.050], m = 3, period = 4.
241        //   annual_r = (1.02*1.03*1.05)^(4/3) - 1 = 0.1398182177864391
242        //   annual_b = (1.01*1.015*1.025)^(4/3) - 1 = 0.06827166330526313
243        //   up_capture = annual_r / annual_b = 2.0479685277516944
244        // Cross-validated against empyrical 0.5.5 up_capture (ann_factor=4).
245        let benchmark = create_returns(&[0.01, -0.02, 0.015, 0.025]);
246        let returns = create_returns(&[0.02, -0.04, 0.030, 0.050]);
247        let stat = UpCaptureRatio::new(Some(4));
248        let result = stat
249            .calculate_from_returns_with_benchmark(&returns, &benchmark)
250            .unwrap();
251        assert!(approx_eq!(
252            f64,
253            result,
254            2.047_968_527_751_694_4,
255            epsilon = 1e-9
256        ));
257    }
258
259    #[rstest]
260    fn test_no_up_periods_is_nan() {
261        // Benchmark never positive -> up subset empty -> NaN.
262        let benchmark = create_returns(&[-0.01, -0.02, -0.015, -0.005]);
263        let returns = create_returns(&[0.02, -0.04, 0.030, -0.010]);
264        let stat = UpCaptureRatio::new(None);
265        let result = stat
266            .calculate_from_returns_with_benchmark(&returns, &benchmark)
267            .unwrap();
268        assert!(result.is_nan());
269    }
270
271    #[rstest]
272    fn test_partial_overlap_inner_join() {
273        // Strategy on days 0..5, benchmark on days 2..7 -> overlap on days 2,3,4 only.
274        let one_day = 86_400_000_000_000_u64;
275        let start = 1_600_000_000_000_000_000_u64;
276
277        let mut returns = BTreeMap::new();
278        for (i, v) in [0.02, -0.04, 0.030, -0.010, 0.050].iter().enumerate() {
279            returns.insert(UnixNanos::from(start + i as u64 * one_day), *v);
280        }
281        let mut benchmark = BTreeMap::new();
282        for (i, v) in [0.015, -0.005, 0.025, 0.01, -0.02].iter().enumerate() {
283            benchmark.insert(UnixNanos::from(start + (i as u64 + 2) * one_day), *v);
284        }
285
286        // Overlap days 2,3,4: r = [0.030, -0.010, 0.050], b = [0.015, -0.005, 0.025].
287        // up subset (b > 0) is days 2,4: r_up = [0.030, 0.050], b_up = [0.015, 0.025],
288        // m = 2, period = 252.
289        //   annual_r = (1.03*1.05)^(252/2) - 1
290        //   annual_b = (1.015*1.025)^(252/2) - 1
291        //   up_capture = annual_r / annual_b = 133.15737653360318
292        // Cross-validated against empyrical 0.5.5 up_capture on the subset (period='daily').
293        let stat = UpCaptureRatio::new(Some(252));
294        let result = stat
295            .calculate_from_returns_with_benchmark(&returns, &benchmark)
296            .unwrap();
297        assert!(approx_eq!(
298            f64,
299            result,
300            133.157_376_533_603_18,
301            epsilon = 1e-9
302        ));
303    }
304
305    #[rstest]
306    fn test_empty_returns_is_nan() {
307        let stat = UpCaptureRatio::new(None);
308        let result = stat
309            .calculate_from_returns_with_benchmark(&create_returns(&[]), &create_returns(&[]))
310            .unwrap();
311        assert!(result.is_nan());
312    }
313
314    #[rstest]
315    fn test_single_overlap_is_nan() {
316        // Only one shared timestamp after inner join -> n < 2 -> NaN.
317        let benchmark = create_returns(&[0.01, -0.02, 0.015]);
318        let returns = create_returns(&[0.02]);
319        let stat = UpCaptureRatio::new(None);
320        let result = stat
321            .calculate_from_returns_with_benchmark(&returns, &benchmark)
322            .unwrap();
323        assert!(result.is_nan());
324    }
325}