nautilus_model/events/position/
adjusted.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2025-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////////////////////////////////////////////////////////////////////////////////
102// Tests
103////////////////////////////////////////////////////////////////////////////////
104#[cfg(test)]
105mod tests {
106    use std::str::FromStr;
107
108    use nautilus_core::UnixNanos;
109    use rstest::*;
110
111    use super::*;
112    use crate::{
113        enums::PositionAdjustmentType,
114        identifiers::{AccountId, InstrumentId, PositionId, StrategyId, TraderId},
115        types::{Currency, Money},
116    };
117
118    fn create_test_commission_adjustment() -> PositionAdjusted {
119        PositionAdjusted::new(
120            TraderId::from("TRADER-001"),
121            StrategyId::from("EMA-CROSS"),
122            InstrumentId::from("BTCUSDT.BINANCE"),
123            PositionId::from("P-001"),
124            AccountId::from("BINANCE-001"),
125            PositionAdjustmentType::Commission,
126            Some(Decimal::from_str("-0.001").unwrap()),
127            None,
128            Some(Ustr::from("O-123")),
129            Default::default(),
130            UnixNanos::from(1_000_000_000),
131            UnixNanos::from(2_000_000_000),
132        )
133    }
134
135    fn create_test_funding_adjustment() -> PositionAdjusted {
136        PositionAdjusted::new(
137            TraderId::from("TRADER-001"),
138            StrategyId::from("EMA-CROSS"),
139            InstrumentId::from("BTCUSD-PERP.BINANCE"),
140            PositionId::from("P-002"),
141            AccountId::from("BINANCE-001"),
142            PositionAdjustmentType::Funding,
143            None,
144            Some(Money::new(-5.50, Currency::USD())),
145            Some(Ustr::from("funding_2024_01_15_08:00")),
146            Default::default(),
147            UnixNanos::from(1_000_000_000),
148            UnixNanos::from(2_000_000_000),
149        )
150    }
151
152    #[rstest]
153    fn test_position_adjustment_commission_new() {
154        let adjustment = create_test_commission_adjustment();
155
156        assert_eq!(adjustment.trader_id, TraderId::from("TRADER-001"));
157        assert_eq!(adjustment.strategy_id, StrategyId::from("EMA-CROSS"));
158        assert_eq!(
159            adjustment.instrument_id,
160            InstrumentId::from("BTCUSDT.BINANCE")
161        );
162        assert_eq!(adjustment.position_id, PositionId::from("P-001"));
163        assert_eq!(adjustment.account_id, AccountId::from("BINANCE-001"));
164        assert_eq!(
165            adjustment.adjustment_type,
166            PositionAdjustmentType::Commission
167        );
168        assert_eq!(
169            adjustment.quantity_change,
170            Some(Decimal::from_str("-0.001").unwrap())
171        );
172        assert_eq!(adjustment.pnl_change, None);
173        assert_eq!(adjustment.reason, Some(Ustr::from("O-123")));
174        assert_eq!(adjustment.ts_event, UnixNanos::from(1_000_000_000));
175        assert_eq!(adjustment.ts_init, UnixNanos::from(2_000_000_000));
176    }
177
178    #[rstest]
179    fn test_position_adjustment_funding_new() {
180        let adjustment = create_test_funding_adjustment();
181
182        assert_eq!(adjustment.trader_id, TraderId::from("TRADER-001"));
183        assert_eq!(adjustment.strategy_id, StrategyId::from("EMA-CROSS"));
184        assert_eq!(
185            adjustment.instrument_id,
186            InstrumentId::from("BTCUSD-PERP.BINANCE")
187        );
188        assert_eq!(adjustment.position_id, PositionId::from("P-002"));
189        assert_eq!(adjustment.account_id, AccountId::from("BINANCE-001"));
190        assert_eq!(adjustment.adjustment_type, PositionAdjustmentType::Funding);
191        assert_eq!(adjustment.quantity_change, None);
192        assert_eq!(
193            adjustment.pnl_change,
194            Some(Money::new(-5.50, Currency::USD()))
195        );
196        assert_eq!(
197            adjustment.reason,
198            Some(Ustr::from("funding_2024_01_15_08:00"))
199        );
200        assert_eq!(adjustment.ts_event, UnixNanos::from(1_000_000_000));
201        assert_eq!(adjustment.ts_init, UnixNanos::from(2_000_000_000));
202    }
203
204    #[rstest]
205    fn test_position_adjustment_clone() {
206        let adjustment1 = create_test_commission_adjustment();
207        let adjustment2 = adjustment1;
208
209        assert_eq!(adjustment1, adjustment2);
210    }
211
212    #[rstest]
213    fn test_position_adjustment_debug() {
214        let adjustment = create_test_commission_adjustment();
215        let debug_str = format!("{adjustment:?}");
216
217        assert!(debug_str.contains("PositionAdjusted"));
218        assert!(debug_str.contains("TRADER-001"));
219        assert!(debug_str.contains("EMA-CROSS"));
220        assert!(debug_str.contains("BTCUSDT.BINANCE"));
221        assert!(debug_str.contains("P-001"));
222        assert!(debug_str.contains("Commission"));
223    }
224
225    #[rstest]
226    fn test_position_adjustment_partial_eq() {
227        let adjustment1 = create_test_commission_adjustment();
228        let mut adjustment2 = create_test_commission_adjustment();
229        adjustment2.event_id = adjustment1.event_id;
230
231        let mut adjustment3 = create_test_commission_adjustment();
232        adjustment3.event_id = adjustment1.event_id;
233        adjustment3.quantity_change = Some(Decimal::from_str("-0.002").unwrap());
234
235        assert_eq!(adjustment1, adjustment2);
236        assert_ne!(adjustment1, adjustment3);
237    }
238
239    #[rstest]
240    fn test_position_adjustment_different_types() {
241        let commission = create_test_commission_adjustment();
242        let funding = create_test_funding_adjustment();
243
244        assert_eq!(
245            commission.adjustment_type,
246            PositionAdjustmentType::Commission
247        );
248        assert_eq!(funding.adjustment_type, PositionAdjustmentType::Funding);
249        assert_ne!(commission.adjustment_type, funding.adjustment_type);
250    }
251
252    #[rstest]
253    fn test_position_adjustment_timestamps() {
254        let adjustment = create_test_commission_adjustment();
255
256        assert_eq!(adjustment.ts_event, UnixNanos::from(1_000_000_000));
257        assert_eq!(adjustment.ts_init, UnixNanos::from(2_000_000_000));
258        assert!(adjustment.ts_event < adjustment.ts_init);
259    }
260
261    #[rstest]
262    fn test_position_adjustment_serialization() {
263        let original = create_test_commission_adjustment();
264
265        let json = serde_json::to_string(&original).unwrap();
266        let deserialized: PositionAdjusted = serde_json::from_str(&json).unwrap();
267
268        assert_eq!(original, deserialized);
269    }
270}