nautilus_model/events/position/
adjusted.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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::{UUID4, UnixNanos};
17use rust_decimal::Decimal;
18use serde::{Deserialize, Serialize};
19use ustr::Ustr;
20
21use crate::{
22    enums::PositionAdjustmentType,
23    identifiers::{AccountId, InstrumentId, PositionId, StrategyId, TraderId},
24    types::Money,
25};
26
27/// Represents an adjustment to a position's quantity or realized PnL.
28///
29/// This event is used to track changes to positions that occur outside of normal
30/// order fills, such as:
31/// - Commission adjustments that affect the actual quantity held (e.g., crypto spot commissions)
32/// - Funding payments that affect realized PnL (e.g., perpetual futures funding)
33#[repr(C)]
34#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
35#[serde(tag = "type")]
36#[cfg_attr(
37    feature = "python",
38    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
39)]
40pub struct PositionAdjusted {
41    /// The trader ID associated with the event.
42    pub trader_id: TraderId,
43    /// The strategy ID associated with the event.
44    pub strategy_id: StrategyId,
45    /// The instrument ID associated with the event.
46    pub instrument_id: InstrumentId,
47    /// The position ID associated with the event.
48    pub position_id: PositionId,
49    /// The account ID associated with the event.
50    pub account_id: AccountId,
51    /// The type of adjustment.
52    pub adjustment_type: PositionAdjustmentType,
53    /// The quantity change (if applicable). Positive increases quantity, negative decreases.
54    pub quantity_change: Option<Decimal>,
55    /// The PnL change (if applicable). Can be positive or negative.
56    pub pnl_change: Option<Money>,
57    /// Optional reason or reference for the adjustment (e.g., order ID, funding period).
58    pub reason: Option<Ustr>,
59    /// The unique identifier for the event.
60    pub event_id: UUID4,
61    /// UNIX timestamp (nanoseconds) when the event occurred.
62    pub ts_event: UnixNanos,
63    /// UNIX timestamp (nanoseconds) when the event was initialized.
64    pub ts_init: UnixNanos,
65}
66
67impl PositionAdjusted {
68    /// Creates a new [`PositionAdjusted`] instance.
69    #[allow(clippy::too_many_arguments)]
70    pub fn new(
71        trader_id: TraderId,
72        strategy_id: StrategyId,
73        instrument_id: InstrumentId,
74        position_id: PositionId,
75        account_id: AccountId,
76        adjustment_type: PositionAdjustmentType,
77        quantity_change: Option<Decimal>,
78        pnl_change: Option<Money>,
79        reason: Option<Ustr>,
80        event_id: UUID4,
81        ts_event: UnixNanos,
82        ts_init: UnixNanos,
83    ) -> Self {
84        Self {
85            trader_id,
86            strategy_id,
87            instrument_id,
88            position_id,
89            account_id,
90            adjustment_type,
91            quantity_change,
92            pnl_change,
93            reason,
94            event_id,
95            ts_event,
96            ts_init,
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use std::str::FromStr;
104
105    use nautilus_core::UnixNanos;
106    use rstest::*;
107
108    use super::*;
109    use crate::{
110        enums::PositionAdjustmentType,
111        identifiers::{AccountId, InstrumentId, PositionId, StrategyId, TraderId},
112        types::{Currency, Money},
113    };
114
115    fn create_test_commission_adjustment() -> PositionAdjusted {
116        PositionAdjusted::new(
117            TraderId::from("TRADER-001"),
118            StrategyId::from("EMA-CROSS"),
119            InstrumentId::from("BTCUSDT.BINANCE"),
120            PositionId::from("P-001"),
121            AccountId::from("BINANCE-001"),
122            PositionAdjustmentType::Commission,
123            Some(Decimal::from_str("-0.001").unwrap()),
124            None,
125            Some(Ustr::from("O-123")),
126            Default::default(),
127            UnixNanos::from(1_000_000_000),
128            UnixNanos::from(2_000_000_000),
129        )
130    }
131
132    fn create_test_funding_adjustment() -> PositionAdjusted {
133        PositionAdjusted::new(
134            TraderId::from("TRADER-001"),
135            StrategyId::from("EMA-CROSS"),
136            InstrumentId::from("BTCUSD-PERP.BINANCE"),
137            PositionId::from("P-002"),
138            AccountId::from("BINANCE-001"),
139            PositionAdjustmentType::Funding,
140            None,
141            Some(Money::new(-5.50, Currency::USD())),
142            Some(Ustr::from("funding_2024_01_15_08:00")),
143            Default::default(),
144            UnixNanos::from(1_000_000_000),
145            UnixNanos::from(2_000_000_000),
146        )
147    }
148
149    #[rstest]
150    fn test_position_adjustment_commission_new() {
151        let adjustment = create_test_commission_adjustment();
152
153        assert_eq!(adjustment.trader_id, TraderId::from("TRADER-001"));
154        assert_eq!(adjustment.strategy_id, StrategyId::from("EMA-CROSS"));
155        assert_eq!(
156            adjustment.instrument_id,
157            InstrumentId::from("BTCUSDT.BINANCE")
158        );
159        assert_eq!(adjustment.position_id, PositionId::from("P-001"));
160        assert_eq!(adjustment.account_id, AccountId::from("BINANCE-001"));
161        assert_eq!(
162            adjustment.adjustment_type,
163            PositionAdjustmentType::Commission
164        );
165        assert_eq!(
166            adjustment.quantity_change,
167            Some(Decimal::from_str("-0.001").unwrap())
168        );
169        assert_eq!(adjustment.pnl_change, None);
170        assert_eq!(adjustment.reason, Some(Ustr::from("O-123")));
171        assert_eq!(adjustment.ts_event, UnixNanos::from(1_000_000_000));
172        assert_eq!(adjustment.ts_init, UnixNanos::from(2_000_000_000));
173    }
174
175    #[rstest]
176    fn test_position_adjustment_funding_new() {
177        let adjustment = create_test_funding_adjustment();
178
179        assert_eq!(adjustment.trader_id, TraderId::from("TRADER-001"));
180        assert_eq!(adjustment.strategy_id, StrategyId::from("EMA-CROSS"));
181        assert_eq!(
182            adjustment.instrument_id,
183            InstrumentId::from("BTCUSD-PERP.BINANCE")
184        );
185        assert_eq!(adjustment.position_id, PositionId::from("P-002"));
186        assert_eq!(adjustment.account_id, AccountId::from("BINANCE-001"));
187        assert_eq!(adjustment.adjustment_type, PositionAdjustmentType::Funding);
188        assert_eq!(adjustment.quantity_change, None);
189        assert_eq!(
190            adjustment.pnl_change,
191            Some(Money::new(-5.50, Currency::USD()))
192        );
193        assert_eq!(
194            adjustment.reason,
195            Some(Ustr::from("funding_2024_01_15_08:00"))
196        );
197        assert_eq!(adjustment.ts_event, UnixNanos::from(1_000_000_000));
198        assert_eq!(adjustment.ts_init, UnixNanos::from(2_000_000_000));
199    }
200
201    #[rstest]
202    fn test_position_adjustment_clone() {
203        let adjustment1 = create_test_commission_adjustment();
204        let adjustment2 = adjustment1;
205
206        assert_eq!(adjustment1, adjustment2);
207    }
208
209    #[rstest]
210    fn test_position_adjustment_debug() {
211        let adjustment = create_test_commission_adjustment();
212        let debug_str = format!("{adjustment:?}");
213
214        assert!(debug_str.contains("PositionAdjusted"));
215        assert!(debug_str.contains("TRADER-001"));
216        assert!(debug_str.contains("EMA-CROSS"));
217        assert!(debug_str.contains("BTCUSDT.BINANCE"));
218        assert!(debug_str.contains("P-001"));
219        assert!(debug_str.contains("Commission"));
220    }
221
222    #[rstest]
223    fn test_position_adjustment_partial_eq() {
224        let adjustment1 = create_test_commission_adjustment();
225        let mut adjustment2 = create_test_commission_adjustment();
226        adjustment2.event_id = adjustment1.event_id;
227
228        let mut adjustment3 = create_test_commission_adjustment();
229        adjustment3.event_id = adjustment1.event_id;
230        adjustment3.quantity_change = Some(Decimal::from_str("-0.002").unwrap());
231
232        assert_eq!(adjustment1, adjustment2);
233        assert_ne!(adjustment1, adjustment3);
234    }
235
236    #[rstest]
237    fn test_position_adjustment_different_types() {
238        let commission = create_test_commission_adjustment();
239        let funding = create_test_funding_adjustment();
240
241        assert_eq!(
242            commission.adjustment_type,
243            PositionAdjustmentType::Commission
244        );
245        assert_eq!(funding.adjustment_type, PositionAdjustmentType::Funding);
246        assert_ne!(commission.adjustment_type, funding.adjustment_type);
247    }
248
249    #[rstest]
250    fn test_position_adjustment_timestamps() {
251        let adjustment = create_test_commission_adjustment();
252
253        assert_eq!(adjustment.ts_event, UnixNanos::from(1_000_000_000));
254        assert_eq!(adjustment.ts_init, UnixNanos::from(2_000_000_000));
255        assert!(adjustment.ts_event < adjustment.ts_init);
256    }
257
258    #[rstest]
259    fn test_position_adjustment_serialization() {
260        let original = create_test_commission_adjustment();
261
262        let json = serde_json::to_string(&original).unwrap();
263        let deserialized: PositionAdjusted = serde_json::from_str(&json).unwrap();
264
265        assert_eq!(original, deserialized);
266    }
267}