Skip to main content

nautilus_model/python/reports/
position.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 nautilus_core::{
17    UUID4,
18    python::{IntoPyObjectNautilusExt, serialization::from_dict_pyo3},
19};
20use pyo3::{basic::CompareOp, prelude::*, types::PyDict};
21use rust_decimal::Decimal;
22
23use crate::{
24    enums::{AvgPxReconciliation, PositionSide},
25    identifiers::{AccountId, InstrumentId, PositionId},
26    reports::position::PositionStatusReport,
27    types::Quantity,
28};
29
30#[pymethods]
31#[pyo3_stub_gen::derive::gen_stub_pymethods]
32impl PositionStatusReport {
33    /// Represents a position status at a point in time.
34    ///
35    /// When `avg_px_open_precision` is set, reconciliation accepts `avg_px_open` within one unit at
36    /// that precision on top of its relative tolerance. A precision above 28 adds no allowance.
37    #[new]
38    #[pyo3(signature = (account_id, instrument_id, position_side, quantity, ts_last, ts_init, report_id=None, venue_position_id=None, avg_px_open=None, avg_px_open_reconciliation=None, avg_px_open_precision=None))]
39    #[expect(clippy::too_many_arguments)]
40    fn py_new(
41        account_id: AccountId,
42        instrument_id: InstrumentId,
43        position_side: PositionSide,
44        quantity: Quantity,
45        ts_last: u64,
46        ts_init: u64,
47        report_id: Option<UUID4>,
48        venue_position_id: Option<PositionId>,
49        avg_px_open: Option<Decimal>,
50        avg_px_open_reconciliation: Option<AvgPxReconciliation>,
51        avg_px_open_precision: Option<u8>,
52    ) -> Self {
53        let report = Self::new(
54            account_id,
55            instrument_id,
56            position_side,
57            quantity,
58            ts_last.into(),
59            ts_init.into(),
60            report_id,
61            venue_position_id,
62            avg_px_open,
63        );
64
65        Self {
66            avg_px_open_reconciliation: avg_px_open_reconciliation.unwrap_or_default(),
67            avg_px_open_precision,
68            ..report
69        }
70    }
71
72    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
73        match op {
74            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
75            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
76            _ => py.NotImplemented(),
77        }
78    }
79
80    fn __repr__(&self) -> String {
81        self.to_string()
82    }
83
84    fn __str__(&self) -> String {
85        self.to_string()
86    }
87
88    #[getter]
89    #[pyo3(name = "account_id")]
90    const fn py_account_id(&self) -> AccountId {
91        self.account_id
92    }
93
94    #[getter]
95    #[pyo3(name = "instrument_id")]
96    const fn py_instrument_id(&self) -> InstrumentId {
97        self.instrument_id
98    }
99
100    #[getter]
101    #[pyo3(name = "strategy_id")]
102    const fn py_strategy_id(&self) -> InstrumentId {
103        self.instrument_id
104    }
105
106    #[getter]
107    #[pyo3(name = "venue_position_id")]
108    const fn py_venue_position_id(&self) -> Option<PositionId> {
109        self.venue_position_id
110    }
111
112    #[getter]
113    #[pyo3(name = "position_side")]
114    fn py_position_side(&self) -> PositionSide {
115        self.position_side
116    }
117
118    #[getter]
119    #[pyo3(name = "quantity")]
120    const fn py_quantity(&self) -> Quantity {
121        self.quantity
122    }
123
124    #[getter]
125    #[pyo3(name = "avg_px_open")]
126    const fn py_avg_px_open(&self) -> Option<Decimal> {
127        self.avg_px_open
128    }
129
130    #[getter]
131    #[pyo3(name = "avg_px_open_reconciliation")]
132    const fn py_avg_px_open_reconciliation(&self) -> AvgPxReconciliation {
133        self.avg_px_open_reconciliation
134    }
135
136    #[getter]
137    #[pyo3(name = "avg_px_open_precision")]
138    const fn py_avg_px_open_precision(&self) -> Option<u8> {
139        self.avg_px_open_precision
140    }
141
142    #[getter]
143    #[pyo3(name = "report_id")]
144    const fn py_report_id(&self) -> UUID4 {
145        self.report_id
146    }
147
148    #[getter]
149    #[pyo3(name = "ts_last")]
150    const fn py_ts_last(&self) -> u64 {
151        self.ts_last.as_u64()
152    }
153
154    #[getter]
155    #[pyo3(name = "ts_init")]
156    const fn py_ts_init(&self) -> u64 {
157        self.ts_init.as_u64()
158    }
159
160    /// Checks if this is a flat position (quantity is zero).
161    #[getter]
162    #[pyo3(name = "is_flat")]
163    fn py_is_flat(&self) -> bool {
164        self.is_flat()
165    }
166
167    /// Checks if this is a long position.
168    #[getter]
169    #[pyo3(name = "is_long")]
170    fn py_is_long(&self) -> bool {
171        self.is_long()
172    }
173
174    /// Checks if this is a short position.
175    #[getter]
176    #[pyo3(name = "is_short")]
177    fn py_is_short(&self) -> bool {
178        self.is_short()
179    }
180
181    /// Creates a `PositionStatusReport` from a Python dictionary.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if conversion from dict fails.
186    #[staticmethod]
187    #[pyo3(name = "from_dict")]
188    pub fn py_from_dict(py: Python<'_>, values: Py<PyDict>) -> PyResult<Self> {
189        from_dict_pyo3(py, values)
190    }
191
192    /// Converts the `PositionStatusReport` to a Python dictionary.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if conversion to dict fails.
197    #[pyo3(name = "to_dict")]
198    pub fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
199        let dict = PyDict::new(py);
200        dict.set_item("type", stringify!(PositionStatusReport))?;
201        dict.set_item("account_id", self.account_id.to_string())?;
202        dict.set_item("instrument_id", self.instrument_id.to_string())?;
203
204        match self.venue_position_id {
205            Some(venue_position_id) => {
206                dict.set_item("venue_position_id", venue_position_id.to_string())?;
207            }
208            None => dict.set_item("venue_position_id", py.None())?,
209        }
210        dict.set_item("position_side", self.position_side.to_string())?;
211        dict.set_item("quantity", self.quantity.to_string())?;
212        dict.set_item("signed_decimal_qty", self.signed_decimal_qty.to_string())?;
213        dict.set_item("report_id", self.report_id.to_string())?;
214        dict.set_item("ts_last", self.ts_last.as_u64())?;
215        dict.set_item("ts_init", self.ts_init.as_u64())?;
216        dict.set_item(
217            "avg_px_open",
218            self.avg_px_open.map(|avg_px_open| avg_px_open.to_string()),
219        )?;
220        dict.set_item(
221            "avg_px_open_reconciliation",
222            self.avg_px_open_reconciliation.to_string(),
223        )?;
224        dict.set_item("avg_px_open_precision", self.avg_px_open_precision)?;
225        Ok(dict.into())
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use rstest::rstest;
232    use rust_decimal_macros::dec;
233
234    use super::*;
235
236    #[rstest]
237    fn test_position_status_report_python_dict_round_trip() {
238        Python::initialize();
239        Python::attach(|py| {
240            let report = PositionStatusReport::py_new(
241                AccountId::from("SIM-001"),
242                InstrumentId::from("AUDUSD.SIM"),
243                PositionSide::Short,
244                Quantity::from("100"),
245                1_000_000_000,
246                2_000_000_000,
247                None,
248                Some(PositionId::from("P-001")),
249                Some(dec!(1.23456)),
250                Some(AvgPxReconciliation::OpeningOnly),
251                Some(4),
252            );
253
254            let values = report.py_to_dict(py).unwrap();
255            let restored =
256                PositionStatusReport::py_from_dict(py, values.extract(py).unwrap()).unwrap();
257
258            assert_eq!(report.avg_px_open, Some(dec!(1.23456)));
259            assert_eq!(
260                report.avg_px_open_reconciliation,
261                AvgPxReconciliation::OpeningOnly
262            );
263            assert_eq!(report.avg_px_open_precision, Some(4));
264            assert_eq!(restored, report);
265        });
266    }
267}