Skip to main content

nautilus_model/python/
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::python::{
17    IntoPyObjectNautilusExt, correctness_error_to_pyvalue_err, serialization::from_dict_pyo3,
18};
19use pyo3::{
20    basic::CompareOp,
21    prelude::*,
22    types::{PyDict, PyList},
23};
24use rust_decimal::{Decimal, prelude::ToPrimitive};
25
26use super::common::commissions_from_vec;
27use crate::{
28    enums::{InstrumentClass, OrderSide, PositionSide},
29    events::{OrderFilled, PositionAdjusted},
30    identifiers::{
31        AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, TraderId,
32        Venue, VenueOrderId,
33    },
34    position::{self, Position},
35    python::instruments::pyobject_to_instrument_any,
36    types::{Currency, Money, Price, Quantity},
37};
38
39#[pyo3_stub_gen::derive::gen_stub_pymethods]
40#[pymethods]
41impl Position {
42    /// Represents a position in a market.
43    ///
44    /// The position ID may be assigned at the trading venue, or can be system
45    /// generated depending on a strategies OMS (Order Management System) settings.
46    /// Replay events and cumulative fill corrections preserve derived state across close and reopen
47    /// cycles.
48    #[new]
49    fn py_new(py: Python, instrument: Py<PyAny>, fill: OrderFilled) -> PyResult<Self> {
50        let instrument_any = pyobject_to_instrument_any(py, instrument)?;
51        Self::new_checked(&instrument_any, fill).map_err(correctness_error_to_pyvalue_err)
52    }
53
54    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
55        match op {
56            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
57            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
58            _ => py.NotImplemented(),
59        }
60    }
61
62    fn __repr__(&self) -> String {
63        self.to_string()
64    }
65
66    fn __str__(&self) -> String {
67        self.to_string()
68    }
69
70    #[getter]
71    #[pyo3(name = "trader_id")]
72    fn py_trader_id(&self) -> TraderId {
73        self.trader_id
74    }
75
76    #[getter]
77    #[pyo3(name = "strategy_id")]
78    fn py_strategy_id(&self) -> StrategyId {
79        self.strategy_id
80    }
81
82    #[getter]
83    #[pyo3(name = "instrument_id")]
84    fn py_instrument_id(&self) -> InstrumentId {
85        self.instrument_id
86    }
87
88    #[getter]
89    #[pyo3(name = "id")]
90    fn py_id(&self) -> PositionId {
91        self.id
92    }
93
94    #[getter]
95    #[pyo3(name = "account_id")]
96    fn py_account_id(&self) -> AccountId {
97        self.account_id
98    }
99
100    /// Returns the instrument symbol.
101    #[getter]
102    #[pyo3(name = "symbol")]
103    fn py_symbol(&self) -> Symbol {
104        self.symbol()
105    }
106
107    /// Returns the trading venue.
108    #[getter]
109    #[pyo3(name = "venue")]
110    fn py_venue(&self) -> Venue {
111        self.venue()
112    }
113
114    #[getter]
115    #[pyo3(name = "opening_order_id")]
116    fn py_opening_order_id(&self) -> ClientOrderId {
117        self.opening_order_id
118    }
119
120    #[getter]
121    #[pyo3(name = "closing_order_id")]
122    fn py_closing_order_id(&self) -> Option<ClientOrderId> {
123        self.closing_order_id
124    }
125
126    #[getter]
127    #[pyo3(name = "entry")]
128    fn py_entry(&self) -> OrderSide {
129        self.entry
130    }
131
132    #[getter]
133    #[pyo3(name = "side")]
134    fn py_side(&self) -> PositionSide {
135        self.side
136    }
137
138    #[getter]
139    #[pyo3(name = "signed_qty")]
140    fn py_signed_qty(&self) -> f64 {
141        self.signed_qty
142    }
143
144    #[getter]
145    #[pyo3(name = "quantity")]
146    fn py_quantity(&self) -> Quantity {
147        self.quantity
148    }
149
150    #[getter]
151    #[pyo3(name = "peak_qty")]
152    fn py_peak_qty(&self) -> Quantity {
153        self.peak_qty
154    }
155
156    #[getter]
157    #[pyo3(name = "price_precision")]
158    fn py_price_precision(&self) -> u8 {
159        self.price_precision
160    }
161
162    #[getter]
163    #[pyo3(name = "size_precision")]
164    fn py_size_precision(&self) -> u8 {
165        self.size_precision
166    }
167
168    #[getter]
169    #[pyo3(name = "multiplier")]
170    fn py_multiplier(&self) -> Quantity {
171        self.multiplier
172    }
173
174    #[getter]
175    #[pyo3(name = "is_inverse")]
176    fn py_is_inverse(&self) -> bool {
177        self.is_inverse
178    }
179
180    #[getter]
181    #[pyo3(name = "instrument_class")]
182    fn py_instrument_class(&self) -> InstrumentClass {
183        self.instrument_class
184    }
185
186    #[getter]
187    #[pyo3(name = "is_spot_currency")]
188    fn py_is_spot_currency(&self) -> bool {
189        self.is_currency_pair
190    }
191
192    #[getter]
193    #[pyo3(name = "base_currency")]
194    fn py_base_currency(&self) -> Option<Currency> {
195        self.base_currency
196    }
197
198    #[getter]
199    #[pyo3(name = "quote_currency")]
200    fn py_quote_currency(&self) -> Currency {
201        self.quote_currency
202    }
203
204    #[getter]
205    #[pyo3(name = "settlement_currency")]
206    fn py_settlement_currency(&self) -> Currency {
207        self.settlement_currency
208    }
209
210    #[getter]
211    #[pyo3(name = "ts_init")]
212    fn py_ts_init(&self) -> u64 {
213        self.ts_init.as_u64()
214    }
215
216    #[getter]
217    #[pyo3(name = "ts_opened")]
218    fn py_ts_opened(&self) -> u64 {
219        self.ts_opened.as_u64()
220    }
221
222    #[getter]
223    #[pyo3(name = "ts_last")]
224    fn py_ts_last(&self) -> u64 {
225        self.ts_last.as_u64()
226    }
227
228    #[getter]
229    #[pyo3(name = "ts_closed")]
230    fn py_ts_closed(&self) -> Option<u64> {
231        self.ts_closed.map(std::convert::Into::into)
232    }
233
234    #[getter]
235    #[pyo3(name = "duration_ns")]
236    fn py_duration_ns(&self) -> u64 {
237        self.duration_ns
238    }
239
240    #[getter]
241    #[pyo3(name = "avg_px_open")]
242    fn py_avg_px_open(&self) -> f64 {
243        self.avg_px_open
244    }
245
246    #[getter]
247    #[pyo3(name = "avg_px_close")]
248    fn py_avg_px_close(&self) -> Option<f64> {
249        self.avg_px_close
250    }
251
252    #[getter]
253    #[pyo3(name = "realized_return")]
254    fn py_realized_return(&self) -> f64 {
255        self.realized_return
256    }
257
258    #[getter]
259    #[pyo3(name = "realized_pnl")]
260    fn py_realized_pnl(&self) -> Option<Money> {
261        self.realized_pnl
262    }
263
264    #[pyo3(name = "events")]
265    fn py_events(&self) -> Vec<OrderFilled> {
266        self.events.clone()
267    }
268
269    #[pyo3(name = "adjustments")]
270    fn py_adjustments(&self) -> Vec<PositionAdjusted> {
271        self.adjustments.clone()
272    }
273
274    /// Returns unique client order IDs from all fill events, sorted.
275    #[pyo3(name = "client_order_ids")]
276    fn py_client_order_ids(&self) -> Vec<ClientOrderId> {
277        self.client_order_ids()
278    }
279
280    /// Returns unique venue order IDs from all fill events, sorted.
281    #[pyo3(name = "venue_order_ids")]
282    fn py_venue_order_ids(&self) -> Vec<VenueOrderId> {
283        self.venue_order_ids()
284    }
285
286    /// Returns unique trade IDs from all fill events, sorted.
287    #[pyo3(name = "trade_ids")]
288    fn py_trade_ids(&self) -> Vec<TradeId> {
289        self.trade_ids()
290    }
291
292    /// Returns the last `OrderFilled` event for the position (if any after purging).
293    #[getter]
294    #[pyo3(name = "last_event")]
295    fn py_last_event(&self) -> Option<OrderFilled> {
296        self.last_event()
297    }
298
299    /// Returns the last `TradeId` for the position (if any after purging).
300    #[getter]
301    #[pyo3(name = "last_trade_id")]
302    fn py_last_trade_id(&self) -> Option<TradeId> {
303        self.last_trade_id()
304    }
305
306    /// Returns the count of order fill events applied to this position.
307    #[getter]
308    #[pyo3(name = "event_count")]
309    fn py_event_count(&self) -> usize {
310        self.events.len()
311    }
312
313    /// Returns whether the position is currently open (has quantity and no close timestamp).
314    #[getter]
315    #[pyo3(name = "is_open")]
316    fn py_is_open(&self) -> bool {
317        self.is_open()
318    }
319
320    /// Returns whether the position is closed (flat with a close timestamp).
321    #[getter]
322    #[pyo3(name = "is_closed")]
323    fn py_is_closed(&self) -> bool {
324        self.is_closed()
325    }
326
327    /// Returns whether the position is long (positive quantity).
328    #[getter]
329    #[pyo3(name = "is_long")]
330    fn py_is_long(&self) -> bool {
331        self.is_long()
332    }
333
334    /// Returns whether the position is short (negative quantity).
335    #[getter]
336    #[pyo3(name = "is_short")]
337    fn py_is_short(&self) -> bool {
338        self.is_short()
339    }
340
341    /// Returns unrealized P&L based on the last price.
342    #[pyo3(name = "unrealized_pnl")]
343    fn py_unrealized_pnl(&self, last: Price) -> PyResult<Money> {
344        self.try_unrealized_pnl(last)
345            .map_err(nautilus_core::python::to_pyvalue_err)
346    }
347
348    /// Returns total P&L (realized + unrealized) based on the last price.
349    #[pyo3(name = "total_pnl")]
350    fn py_total_pnl(&self, last: Price) -> PyResult<Money> {
351        self.try_total_pnl(last)
352            .map_err(nautilus_core::python::to_pyvalue_err)
353    }
354
355    /// Returns the cumulative commissions for the position as a vector.
356    #[pyo3(name = "commissions")]
357    fn py_commissions(&self) -> Vec<Money> {
358        self.commissions()
359    }
360
361    /// Applies an `OrderFilled` event to this position.
362    #[pyo3(name = "apply")]
363    fn py_apply(&mut self, fill: &OrderFilled) -> PyResult<()> {
364        self.try_apply(fill)
365            .map_err(correctness_error_to_pyvalue_err)
366    }
367
368    /// Applies a position adjustment event.
369    ///
370    /// This method handles adjustments to position quantity or realized PnL that occur
371    /// outside of normal order fills, such as:
372    /// - Commission adjustments in base currency (crypto spot markets).
373    /// - Funding payments (perpetual futures).
374    ///
375    /// The adjustment event is stored in the position's adjustment history for full audit trail.
376    #[pyo3(name = "apply_adjustment")]
377    fn py_apply_adjustment(&mut self, adjustment: PositionAdjusted) {
378        self.apply_adjustment(adjustment);
379    }
380
381    /// Purges all order fill events for the given client order ID and recalculates derived state.
382    ///
383    /// # Warning
384    ///
385    /// This operation recalculates the entire position from scratch after removing the specified
386    /// order's fills. This is an expensive operation and should be used sparingly.
387    #[pyo3(name = "purge_events_for_order")]
388    fn py_purge_events_for_order(&mut self, client_order_id: ClientOrderId) {
389        self.purge_events_for_order(client_order_id);
390    }
391
392    /// Returns whether the given order side is opposite to the position entry side.
393    #[pyo3(name = "is_opposite_side")]
394    fn py_is_opposite_side(&self, side: OrderSide) -> bool {
395        self.is_opposite_side(side)
396    }
397
398    /// Calculates profit and loss from the given prices and quantity.
399    #[pyo3(name = "calculate_pnl")]
400    fn py_calculate_pnl(
401        &self,
402        avg_px_open: f64,
403        avg_px_close: f64,
404        quantity: Quantity,
405    ) -> PyResult<Money> {
406        self.try_calculate_pnl(avg_px_open, avg_px_close, quantity)
407            .map_err(nautilus_core::python::to_pyvalue_err)
408    }
409
410    /// Calculates the notional value based on the last price.
411    #[pyo3(name = "notional_value")]
412    fn py_notional_value(&self, price: Price) -> PyResult<Money> {
413        self.try_notional_value(price)
414            .map_err(nautilus_core::python::to_pyvalue_err)
415    }
416
417    /// Constructs a [`Position`] from a Python dict.
418    ///
419    /// # Errors
420    ///
421    /// Returns a `PyErr` if deserialization from the Python dict fails.
422    #[staticmethod]
423    #[pyo3(name = "from_dict")]
424    pub fn py_from_dict(py: Python<'_>, values: Py<PyDict>) -> PyResult<Self> {
425        from_dict_pyo3(py, values)
426    }
427
428    /// Converts this [`Position`] into a Python dict.
429    ///
430    /// # Errors
431    ///
432    /// Returns a `PyErr` if serialization into a Python dict fails.
433    #[pyo3(name = "to_dict")]
434    fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
435        let dict = PyDict::new(py);
436        dict.set_item("type", stringify!(Position))?;
437        let events_dict: PyResult<Vec<_>> = self.events.iter().map(|e| e.py_to_dict(py)).collect();
438        dict.set_item("events", events_dict?)?;
439        let adjustments_dict: PyResult<Vec<_>> =
440            self.adjustments.iter().map(|a| a.py_to_dict(py)).collect();
441        dict.set_item("adjustments", adjustments_dict?)?;
442        dict.set_item("trader_id", self.trader_id.to_string())?;
443        dict.set_item("strategy_id", self.strategy_id.to_string())?;
444        dict.set_item("instrument_id", self.instrument_id.to_string())?;
445        dict.set_item("position_id", self.id.to_string())?;
446        dict.set_item("account_id", self.account_id.to_string())?;
447        dict.set_item("opening_order_id", self.opening_order_id.to_string())?;
448
449        match self.closing_order_id {
450            Some(closing_order_id) => {
451                dict.set_item("closing_order_id", closing_order_id.to_string())?;
452            }
453            None => dict.set_item("closing_order_id", py.None())?,
454        }
455        dict.set_item("entry", self.entry.to_string())?;
456        dict.set_item("side", self.side.to_string())?;
457        dict.set_item("signed_qty", self.signed_qty.to_f64())?;
458        dict.set_item("quantity", self.quantity.to_string())?;
459        dict.set_item("peak_qty", self.peak_qty.to_string())?;
460        dict.set_item("price_precision", self.price_precision.to_u8())?;
461        dict.set_item("size_precision", self.size_precision.to_u8())?;
462        dict.set_item("multiplier", self.multiplier.to_string())?;
463        dict.set_item("is_inverse", self.is_inverse)?;
464
465        match self.base_currency {
466            Some(base_currency) => {
467                dict.set_item("base_currency", base_currency.code.to_string())?;
468            }
469            None => dict.set_item("base_currency", py.None())?,
470        }
471        dict.set_item("quote_currency", self.quote_currency.code.to_string())?;
472        dict.set_item(
473            "settlement_currency",
474            self.settlement_currency.code.to_string(),
475        )?;
476        dict.set_item("ts_init", self.ts_init.as_u64())?;
477        dict.set_item("ts_opened", self.ts_opened.as_u64())?;
478        dict.set_item("ts_last", self.ts_last.as_u64())?;
479        match self.ts_closed {
480            Some(ts_closed) => dict.set_item("ts_closed", ts_closed.as_u64())?,
481            None => dict.set_item("ts_closed", py.None())?,
482        }
483        dict.set_item("duration_ns", self.duration_ns.to_u64())?;
484        dict.set_item("avg_px_open", self.avg_px_open)?;
485        match self.avg_px_close {
486            Some(avg_px_close) => dict.set_item("avg_px_close", avg_px_close)?,
487            None => dict.set_item("avg_px_close", py.None())?,
488        }
489        dict.set_item("realized_return", self.realized_return)?;
490        match self.realized_pnl {
491            Some(realized_pnl) => dict.set_item("realized_pnl", realized_pnl.to_string())?,
492            None => dict.set_item("realized_pnl", py.None())?,
493        }
494        let venue_order_ids_list =
495            PyList::new(py, self.venue_order_ids().iter().map(ToString::to_string))?;
496        dict.set_item("venue_order_ids", venue_order_ids_list)?;
497        let trade_ids_list = PyList::new(py, self.trade_ids().iter().map(ToString::to_string))?;
498        dict.set_item("trade_ids", trade_ids_list)?;
499        dict.set_item("buy_qty", self.buy_qty.to_string())?;
500        dict.set_item("sell_qty", self.sell_qty.to_string())?;
501        dict.set_item("commissions", commissions_from_vec(py, self.commissions())?)?;
502        Ok(dict.into())
503    }
504}
505
506/// Replays position legs onto a hypothetical NETTING position in `ts_opened`
507/// order, returning `(net_signed_qty, net_avg_px_open)`.
508///
509/// Each leg is `(signed_qty, avg_px_open, ts_opened_ns)`. Rules follow
510/// `Position.apply`:
511/// - Same-side legs produce a quantity-weighted average open price.
512/// - Opposite-side legs partial-close at the existing average.
513/// - A leg that crosses zero makes the residual take that leg's price.
514///
515/// Zero-quantity legs are skipped. Sort is stable on `ts_opened`; the caller
516/// orders ties (e.g. by `position_id`).
517#[must_use]
518#[pyfunction]
519#[pyo3(name = "fold_net_position")]
520#[allow(
521    clippy::needless_pass_by_value,
522    reason = "PyO3 cannot extract Python list into &[T]; Vec<T> ownership is required"
523)]
524pub fn py_fold_net_position(legs: Vec<(Decimal, Decimal, u64)>) -> (Decimal, Decimal) {
525    position::fold_net_position(&legs)
526}