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