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, liquidation_price=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        liquidation_price: Option<Decimal>,
53    ) -> Self {
54        let report = Self::new(
55            account_id,
56            instrument_id,
57            position_side,
58            quantity,
59            ts_last.into(),
60            ts_init.into(),
61            report_id,
62            venue_position_id,
63            avg_px_open,
64        );
65
66        Self {
67            avg_px_open_reconciliation: avg_px_open_reconciliation.unwrap_or_default(),
68            avg_px_open_precision,
69            liquidation_price,
70            ..report
71        }
72    }
73
74    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
75        match op {
76            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
77            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
78            _ => py.NotImplemented(),
79        }
80    }
81
82    fn __repr__(&self) -> String {
83        self.to_string()
84    }
85
86    fn __str__(&self) -> String {
87        self.to_string()
88    }
89
90    #[getter]
91    #[pyo3(name = "account_id")]
92    const fn py_account_id(&self) -> AccountId {
93        self.account_id
94    }
95
96    #[getter]
97    #[pyo3(name = "instrument_id")]
98    const fn py_instrument_id(&self) -> InstrumentId {
99        self.instrument_id
100    }
101
102    #[getter]
103    #[pyo3(name = "strategy_id")]
104    const fn py_strategy_id(&self) -> InstrumentId {
105        self.instrument_id
106    }
107
108    #[getter]
109    #[pyo3(name = "venue_position_id")]
110    const fn py_venue_position_id(&self) -> Option<PositionId> {
111        self.venue_position_id
112    }
113
114    #[getter]
115    #[pyo3(name = "position_side")]
116    fn py_position_side(&self) -> PositionSide {
117        self.position_side
118    }
119
120    #[getter]
121    #[pyo3(name = "quantity")]
122    const fn py_quantity(&self) -> Quantity {
123        self.quantity
124    }
125
126    #[getter]
127    #[pyo3(name = "avg_px_open")]
128    const fn py_avg_px_open(&self) -> Option<Decimal> {
129        self.avg_px_open
130    }
131
132    #[getter]
133    #[pyo3(name = "avg_px_open_reconciliation")]
134    const fn py_avg_px_open_reconciliation(&self) -> AvgPxReconciliation {
135        self.avg_px_open_reconciliation
136    }
137
138    #[getter]
139    #[pyo3(name = "avg_px_open_precision")]
140    const fn py_avg_px_open_precision(&self) -> Option<u8> {
141        self.avg_px_open_precision
142    }
143
144    #[getter]
145    #[pyo3(name = "liquidation_price")]
146    const fn py_liquidation_price(&self) -> Option<Decimal> {
147        self.liquidation_price
148    }
149
150    #[getter]
151    #[pyo3(name = "report_id")]
152    const fn py_report_id(&self) -> UUID4 {
153        self.report_id
154    }
155
156    #[getter]
157    #[pyo3(name = "ts_last")]
158    const fn py_ts_last(&self) -> u64 {
159        self.ts_last.as_u64()
160    }
161
162    #[getter]
163    #[pyo3(name = "ts_init")]
164    const fn py_ts_init(&self) -> u64 {
165        self.ts_init.as_u64()
166    }
167
168    /// Checks if this is a flat position (quantity is zero).
169    #[getter]
170    #[pyo3(name = "is_flat")]
171    fn py_is_flat(&self) -> bool {
172        self.is_flat()
173    }
174
175    /// Checks if this is a long position.
176    #[getter]
177    #[pyo3(name = "is_long")]
178    fn py_is_long(&self) -> bool {
179        self.is_long()
180    }
181
182    /// Checks if this is a short position.
183    #[getter]
184    #[pyo3(name = "is_short")]
185    fn py_is_short(&self) -> bool {
186        self.is_short()
187    }
188
189    /// Creates a `PositionStatusReport` from a Python dictionary.
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if conversion from dict fails.
194    #[staticmethod]
195    #[pyo3(name = "from_dict")]
196    pub fn py_from_dict(py: Python<'_>, values: Py<PyDict>) -> PyResult<Self> {
197        from_dict_pyo3(py, values)
198    }
199
200    /// Converts the `PositionStatusReport` to a Python dictionary.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if conversion to dict fails.
205    #[pyo3(name = "to_dict")]
206    pub fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
207        let dict = PyDict::new(py);
208        dict.set_item("type", stringify!(PositionStatusReport))?;
209        dict.set_item("account_id", self.account_id.to_string())?;
210        dict.set_item("instrument_id", self.instrument_id.to_string())?;
211
212        match self.venue_position_id {
213            Some(venue_position_id) => {
214                dict.set_item("venue_position_id", venue_position_id.to_string())?;
215            }
216            None => dict.set_item("venue_position_id", py.None())?,
217        }
218        dict.set_item("position_side", self.position_side.to_string())?;
219        dict.set_item("quantity", self.quantity.to_string())?;
220        dict.set_item("signed_decimal_qty", self.signed_decimal_qty.to_string())?;
221        dict.set_item("report_id", self.report_id.to_string())?;
222        dict.set_item("ts_last", self.ts_last.as_u64())?;
223        dict.set_item("ts_init", self.ts_init.as_u64())?;
224        dict.set_item(
225            "avg_px_open",
226            self.avg_px_open.map(|avg_px_open| avg_px_open.to_string()),
227        )?;
228        dict.set_item(
229            "avg_px_open_reconciliation",
230            self.avg_px_open_reconciliation.to_string(),
231        )?;
232        dict.set_item("avg_px_open_precision", self.avg_px_open_precision)?;
233        dict.set_item(
234            "liquidation_price",
235            self.liquidation_price.map(|price| price.to_string()),
236        )?;
237        Ok(dict.into())
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use rstest::rstest;
244    use rust_decimal_macros::dec;
245
246    use super::*;
247
248    #[rstest]
249    #[case::reported(Some(dec!(0.87654321987654321)))]
250    #[case::zero(Some(Decimal::ZERO))]
251    #[case::unavailable(None)]
252    fn test_position_status_report_python_dict_round_trip(
253        #[case] liquidation_price: Option<Decimal>,
254    ) {
255        Python::initialize();
256        Python::attach(|py| {
257            let report = PositionStatusReport::py_new(
258                AccountId::from("SIM-001"),
259                InstrumentId::from("AUDUSD.SIM"),
260                PositionSide::Short,
261                Quantity::from("100"),
262                1_000_000_000,
263                2_000_000_000,
264                None,
265                Some(PositionId::from("P-001")),
266                Some(dec!(1.23456)),
267                Some(AvgPxReconciliation::OpeningOnly),
268                Some(4),
269                liquidation_price,
270            );
271
272            let values = report.py_to_dict(py).unwrap();
273            let restored =
274                PositionStatusReport::py_from_dict(py, values.extract(py).unwrap()).unwrap();
275
276            assert_eq!(report.avg_px_open, Some(dec!(1.23456)));
277            assert_eq!(
278                report.avg_px_open_reconciliation,
279                AvgPxReconciliation::OpeningOnly
280            );
281            assert_eq!(report.avg_px_open_precision, Some(4));
282            assert_eq!(report.py_liquidation_price(), liquidation_price);
283            assert_eq!(restored, report);
284        });
285    }
286}