nautilus_analysis/statistics/
profit_factor.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Posei Systems Pty Ltd. All rights reserved.
3//  https://poseitrader.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
16use crate::{Returns, statistic::PortfolioStatistic};
17
18/// Calculates the profit factor based on portfolio returns.
19///
20/// Profit factor is defined as the ratio of gross profits to gross losses:
21/// |Total Positive Returns| / |Total Negative Returns|
22///
23/// A profit factor greater than 1.0 indicates a profitable strategy, while
24/// a factor less than 1.0 indicates losses exceed gains.
25#[repr(C)]
26#[derive(Debug)]
27#[cfg_attr(
28    feature = "python",
29    pyo3::pyclass(module = "posei_trader.core.nautilus_pyo3.analysis")
30)]
31pub struct ProfitFactor {}
32
33impl PortfolioStatistic for ProfitFactor {
34    type Item = f64;
35
36    fn name(&self) -> String {
37        stringify!(ProfitFactor).to_string()
38    }
39
40    fn calculate_from_returns(&self, returns: &Returns) -> Option<Self::Item> {
41        if !self.check_valid_returns(returns) {
42            return Some(f64::NAN);
43        }
44
45        let (positive_returns_sum, negative_returns_sum) =
46            returns
47                .values()
48                .fold((0.0, 0.0), |(pos_sum, neg_sum), &pnl| {
49                    if pnl >= 0.0 {
50                        (pos_sum + pnl, neg_sum)
51                    } else {
52                        (pos_sum, neg_sum + pnl)
53                    }
54                });
55
56        if negative_returns_sum == 0.0 {
57            return Some(f64::NAN);
58        }
59        Some((positive_returns_sum / negative_returns_sum).abs())
60    }
61}
62
63#[cfg(test)]
64mod profit_factor_tests {
65    use std::collections::BTreeMap;
66
67    use nautilus_core::UnixNanos;
68    use rstest::rstest;
69
70    use super::*;
71
72    fn create_returns(values: Vec<f64>) -> Returns {
73        let mut new_return = BTreeMap::new();
74        for (i, value) in values.iter().enumerate() {
75            new_return.insert(UnixNanos::from(i as u64), *value);
76        }
77
78        new_return
79    }
80
81    #[rstest]
82    fn test_empty_returns() {
83        let profit_factor = ProfitFactor {};
84        let returns = create_returns(vec![]);
85        let result = profit_factor.calculate_from_returns(&returns);
86        assert!(result.is_some());
87        assert!(result.unwrap().is_nan());
88    }
89
90    #[rstest]
91    fn test_all_positive() {
92        let profit_factor = ProfitFactor {};
93        let returns = create_returns(vec![10.0, 20.0, 30.0]);
94        let result = profit_factor.calculate_from_returns(&returns);
95        assert!(result.is_some());
96        assert!(result.unwrap().is_nan());
97    }
98
99    #[rstest]
100    fn test_all_negative() {
101        let profit_factor = ProfitFactor {};
102        let returns = create_returns(vec![-10.0, -20.0, -30.0]);
103        let result = profit_factor.calculate_from_returns(&returns);
104        assert!(result.is_some());
105        assert_eq!(result.unwrap(), 0.0);
106    }
107
108    #[rstest]
109    fn test_mixed_returns() {
110        let profit_factor = ProfitFactor {};
111        let returns = create_returns(vec![10.0, -20.0, 30.0, -40.0]);
112        let result = profit_factor.calculate_from_returns(&returns);
113        assert!(result.is_some());
114        // (10.0 + 30.0) / |-20.0 + -40.0| = 40 / 60 = 0.666...
115        assert_eq!(result.unwrap(), 0.6666666666666666);
116    }
117
118    #[rstest]
119    fn test_with_zero() {
120        let profit_factor = ProfitFactor {};
121        let returns = create_returns(vec![10.0, 0.0, -20.0, -30.0]);
122        let result = profit_factor.calculate_from_returns(&returns);
123        assert!(result.is_some());
124        // (10.0 + 0.0) / |-20.0 + -30.0| = 10 / 50 = 0.2
125        assert_eq!(result.unwrap(), 0.2);
126    }
127
128    #[rstest]
129    fn test_equal_positive_negative() {
130        let profit_factor = ProfitFactor {};
131        let returns = create_returns(vec![20.0, -20.0]);
132        let result = profit_factor.calculate_from_returns(&returns);
133        assert!(result.is_some());
134        assert_eq!(result.unwrap(), 1.0);
135    }
136
137    #[rstest]
138    fn test_name() {
139        let profit_factor = ProfitFactor {};
140        assert_eq!(profit_factor.name(), "ProfitFactor");
141    }
142}