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}