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