Skip to main content

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