nautilus_analysis/statistics/
long_ratio.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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
16use std::fmt::Display;
17
18use nautilus_model::{enums::OrderSide, position::Position};
19
20use crate::{Returns, statistic::PortfolioStatistic};
21
22#[repr(C)]
23#[derive(Debug, Clone)]
24#[cfg_attr(
25    feature = "python",
26    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis")
27)]
28pub struct LongRatio {
29    pub precision: usize,
30}
31
32impl LongRatio {
33    /// Creates a new [`LongRatio`] instance.
34    #[must_use]
35    pub fn new(precision: Option<usize>) -> Self {
36        Self {
37            precision: precision.unwrap_or(2),
38        }
39    }
40}
41
42impl Display for LongRatio {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        write!(f, "Long Ratio")
45    }
46}
47
48impl PortfolioStatistic for LongRatio {
49    type Item = f64;
50
51    fn name(&self) -> String {
52        self.to_string()
53    }
54
55    fn calculate_from_positions(&self, positions: &[Position]) -> Option<Self::Item> {
56        if positions.is_empty() {
57            return None;
58        }
59
60        let longs: Vec<&Position> = positions
61            .iter()
62            .filter(|p| matches!(p.entry, OrderSide::Buy))
63            .collect();
64
65        let value = longs.len() as f64 / positions.len() as f64;
66
67        let scale = 10f64.powi(self.precision as i32);
68        Some((value * scale).round() / scale)
69    }
70    fn calculate_from_returns(&self, _returns: &Returns) -> Option<Self::Item> {
71        None
72    }
73
74    fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
75        None
76    }
77}
78
79////////////////////////////////////////////////////////////////////////////////
80// Tests
81////////////////////////////////////////////////////////////////////////////////
82
83#[cfg(test)]
84mod tests {
85    use std::collections::HashMap;
86
87    use nautilus_core::{UnixNanos, approx_eq};
88    use nautilus_model::{
89        enums::{InstrumentClass, OrderSide},
90        identifiers::{
91            AccountId, ClientOrderId, PositionId,
92            stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
93        },
94        types::{Currency, Quantity},
95    };
96    use rstest::rstest;
97
98    use super::*;
99
100    fn create_test_position(side: OrderSide) -> Position {
101        Position {
102            events: Vec::new(),
103            trader_id: trader_id(),
104            strategy_id: strategy_id_ema_cross(),
105            instrument_id: instrument_id_aud_usd_sim(),
106            id: PositionId::new("test-position"),
107            account_id: AccountId::new("test-account"),
108            opening_order_id: ClientOrderId::default(),
109            closing_order_id: None,
110            entry: side,
111            side: nautilus_model::enums::PositionSide::NoPositionSide,
112            signed_qty: 0.0,
113            quantity: Quantity::default(),
114            peak_qty: Quantity::default(),
115            price_precision: 2,
116            size_precision: 2,
117            multiplier: Quantity::default(),
118            is_inverse: false,
119            base_currency: None,
120            quote_currency: Currency::USD(),
121            settlement_currency: Currency::USD(),
122            ts_init: UnixNanos::default(),
123            ts_opened: UnixNanos::default(),
124            ts_last: UnixNanos::default(),
125            ts_closed: None,
126            duration_ns: 2,
127            avg_px_open: 0.0,
128            avg_px_close: None,
129            realized_return: 0.0,
130            realized_pnl: None,
131            trade_ids: Vec::new(),
132            buy_qty: Quantity::default(),
133            sell_qty: Quantity::default(),
134            commissions: HashMap::new(),
135            adjustments: Vec::new(),
136            instrument_class: InstrumentClass::Spot,
137            is_currency_pair: true,
138        }
139    }
140
141    #[rstest]
142    fn test_empty_positions() {
143        let long_ratio = LongRatio::new(None);
144        let result = long_ratio.calculate_from_positions(&[]);
145        assert!(result.is_none());
146    }
147
148    #[rstest]
149    fn test_all_long_positions() {
150        let long_ratio = LongRatio::new(None);
151        let positions = vec![
152            create_test_position(OrderSide::Buy),
153            create_test_position(OrderSide::Buy),
154            create_test_position(OrderSide::Buy),
155        ];
156
157        let result = long_ratio.calculate_from_positions(&positions);
158        assert!(result.is_some());
159        assert!(approx_eq!(f64, result.unwrap(), 1.00, epsilon = 1e-9));
160    }
161
162    #[rstest]
163    fn test_all_short_positions() {
164        let long_ratio = LongRatio::new(None);
165        let positions = vec![
166            create_test_position(OrderSide::Sell),
167            create_test_position(OrderSide::Sell),
168            create_test_position(OrderSide::Sell),
169        ];
170
171        let result = long_ratio.calculate_from_positions(&positions);
172        assert!(result.is_some());
173        assert!(approx_eq!(f64, result.unwrap(), 0.00, epsilon = 1e-9));
174    }
175
176    #[rstest]
177    fn test_mixed_positions() {
178        let long_ratio = LongRatio::new(None);
179        let positions = vec![
180            create_test_position(OrderSide::Buy),
181            create_test_position(OrderSide::Sell),
182            create_test_position(OrderSide::Buy),
183            create_test_position(OrderSide::Sell),
184        ];
185
186        let result = long_ratio.calculate_from_positions(&positions);
187        assert!(result.is_some());
188        assert!(approx_eq!(f64, result.unwrap(), 0.50, epsilon = 1e-9));
189    }
190
191    #[rstest]
192    fn test_custom_precision() {
193        let long_ratio = LongRatio::new(Some(3));
194        let positions = vec![
195            create_test_position(OrderSide::Buy),
196            create_test_position(OrderSide::Buy),
197            create_test_position(OrderSide::Sell),
198        ];
199
200        let result = long_ratio.calculate_from_positions(&positions);
201        assert!(result.is_some());
202        assert!(approx_eq!(f64, result.unwrap(), 0.667, epsilon = 1e-9));
203    }
204
205    #[rstest]
206    fn test_single_position_long() {
207        let long_ratio = LongRatio::new(None);
208        let positions = vec![create_test_position(OrderSide::Buy)];
209
210        let result = long_ratio.calculate_from_positions(&positions);
211        assert!(result.is_some());
212        assert!(approx_eq!(f64, result.unwrap(), 1.00, epsilon = 1e-9));
213    }
214
215    #[rstest]
216    fn test_single_position_short() {
217        let long_ratio = LongRatio::new(None);
218        let positions = vec![create_test_position(OrderSide::Sell)];
219
220        let result = long_ratio.calculate_from_positions(&positions);
221        assert!(result.is_some());
222        assert!(approx_eq!(f64, result.unwrap(), 0.00, epsilon = 1e-9));
223    }
224
225    #[rstest]
226    fn test_zero_precision() {
227        let long_ratio = LongRatio::new(Some(0));
228        let positions = vec![
229            create_test_position(OrderSide::Buy),
230            create_test_position(OrderSide::Buy),
231            create_test_position(OrderSide::Sell),
232        ];
233
234        let result = long_ratio.calculate_from_positions(&positions);
235        assert!(result.is_some());
236        assert!(approx_eq!(f64, result.unwrap(), 1.00, epsilon = 1e-9));
237    }
238
239    #[rstest]
240    fn test_name() {
241        let long_ratio = LongRatio::new(None);
242        assert_eq!(long_ratio.name(), "Long Ratio");
243    }
244}