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        // Use `entry` (the opening order side) rather than `side` because
61        // closed positions have side == PositionSide::Flat
62        let long_count = positions
63            .iter()
64            .filter(|p| p.entry == OrderSide::Buy)
65            .count();
66
67        let value = long_count as f64 / positions.len() as f64;
68
69        let scale = 10f64.powi(self.precision as i32);
70        Some((value * scale).round() / scale)
71    }
72    fn calculate_from_returns(&self, _returns: &Returns) -> Option<Self::Item> {
73        None
74    }
75
76    fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
77        None
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use ahash::AHashMap;
84    use nautilus_core::{UnixNanos, approx_eq};
85    use nautilus_model::{
86        enums::{InstrumentClass, PositionSide},
87        identifiers::{
88            AccountId, ClientOrderId, PositionId,
89            stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
90        },
91        types::{Currency, Quantity},
92    };
93    use rstest::rstest;
94
95    use super::*;
96
97    /// Creates a closed position with the given entry side.
98    /// Closed positions have side == Flat, so we test with `entry` field.
99    fn create_closed_position(entry: OrderSide) -> Position {
100        Position {
101            events: Vec::new(),
102            trader_id: trader_id(),
103            strategy_id: strategy_id_ema_cross(),
104            instrument_id: instrument_id_aud_usd_sim(),
105            id: PositionId::new("test-position"),
106            account_id: AccountId::new("test-account"),
107            opening_order_id: ClientOrderId::default(),
108            closing_order_id: None,
109            entry,
110            side: PositionSide::Flat, // Closed positions are Flat
111            signed_qty: 0.0,
112            quantity: Quantity::default(),
113            peak_qty: Quantity::default(),
114            price_precision: 2,
115            size_precision: 2,
116            multiplier: Quantity::default(),
117            is_inverse: false,
118            base_currency: None,
119            quote_currency: Currency::USD(),
120            settlement_currency: Currency::USD(),
121            ts_init: UnixNanos::default(),
122            ts_opened: UnixNanos::default(),
123            ts_last: UnixNanos::default(),
124            ts_closed: Some(UnixNanos::from(1)), // Mark as closed
125            duration_ns: 2,
126            avg_px_open: 0.0,
127            avg_px_close: Some(0.0),
128            realized_return: 0.0,
129            realized_pnl: None,
130            trade_ids: Vec::new(),
131            buy_qty: Quantity::default(),
132            sell_qty: Quantity::default(),
133            commissions: AHashMap::new(),
134            adjustments: Vec::new(),
135            instrument_class: InstrumentClass::Spot,
136            is_currency_pair: true,
137        }
138    }
139
140    #[rstest]
141    fn test_empty_positions() {
142        let long_ratio = LongRatio::new(None);
143        let result = long_ratio.calculate_from_positions(&[]);
144        assert!(result.is_none());
145    }
146
147    #[rstest]
148    fn test_all_long_positions() {
149        let long_ratio = LongRatio::new(None);
150        let positions = vec![
151            create_closed_position(OrderSide::Buy),
152            create_closed_position(OrderSide::Buy),
153            create_closed_position(OrderSide::Buy),
154        ];
155
156        let result = long_ratio.calculate_from_positions(&positions);
157        assert!(result.is_some());
158        assert!(approx_eq!(f64, result.unwrap(), 1.00, epsilon = 1e-9));
159    }
160
161    #[rstest]
162    fn test_all_short_positions() {
163        let long_ratio = LongRatio::new(None);
164        let positions = vec![
165            create_closed_position(OrderSide::Sell),
166            create_closed_position(OrderSide::Sell),
167            create_closed_position(OrderSide::Sell),
168        ];
169
170        let result = long_ratio.calculate_from_positions(&positions);
171        assert!(result.is_some());
172        assert!(approx_eq!(f64, result.unwrap(), 0.00, epsilon = 1e-9));
173    }
174
175    #[rstest]
176    fn test_mixed_positions() {
177        let long_ratio = LongRatio::new(None);
178        let positions = vec![
179            create_closed_position(OrderSide::Buy),
180            create_closed_position(OrderSide::Sell),
181            create_closed_position(OrderSide::Buy),
182            create_closed_position(OrderSide::Sell),
183        ];
184
185        let result = long_ratio.calculate_from_positions(&positions);
186        assert!(result.is_some());
187        assert!(approx_eq!(f64, result.unwrap(), 0.50, epsilon = 1e-9));
188    }
189
190    #[rstest]
191    fn test_custom_precision() {
192        let long_ratio = LongRatio::new(Some(3));
193        let positions = vec![
194            create_closed_position(OrderSide::Buy),
195            create_closed_position(OrderSide::Buy),
196            create_closed_position(OrderSide::Sell),
197        ];
198
199        let result = long_ratio.calculate_from_positions(&positions);
200        assert!(result.is_some());
201        assert!(approx_eq!(f64, result.unwrap(), 0.667, epsilon = 1e-9));
202    }
203
204    #[rstest]
205    fn test_single_position_long() {
206        let long_ratio = LongRatio::new(None);
207        let positions = vec![create_closed_position(OrderSide::Buy)];
208
209        let result = long_ratio.calculate_from_positions(&positions);
210        assert!(result.is_some());
211        assert!(approx_eq!(f64, result.unwrap(), 1.00, epsilon = 1e-9));
212    }
213
214    #[rstest]
215    fn test_single_position_short() {
216        let long_ratio = LongRatio::new(None);
217        let positions = vec![create_closed_position(OrderSide::Sell)];
218
219        let result = long_ratio.calculate_from_positions(&positions);
220        assert!(result.is_some());
221        assert!(approx_eq!(f64, result.unwrap(), 0.00, epsilon = 1e-9));
222    }
223
224    #[rstest]
225    fn test_zero_precision() {
226        let long_ratio = LongRatio::new(Some(0));
227        let positions = vec![
228            create_closed_position(OrderSide::Buy),
229            create_closed_position(OrderSide::Buy),
230            create_closed_position(OrderSide::Sell),
231        ];
232
233        let result = long_ratio.calculate_from_positions(&positions);
234        assert!(result.is_some());
235        assert!(approx_eq!(f64, result.unwrap(), 1.00, epsilon = 1e-9));
236    }
237
238    #[rstest]
239    fn test_name() {
240        let long_ratio = LongRatio::new(None);
241        assert_eq!(long_ratio.name(), "Long Ratio");
242    }
243}