Skip to main content

nautilus_portfolio/
config.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_common::config::{ConfigError, ConfigErrorCollector, ConfigResult};
17use nautilus_core::serialization::default_true;
18use serde::{Deserialize, Serialize};
19
20/// Configuration for `Portfolio` instances.
21#[cfg_attr(
22    feature = "python",
23    pyo3::pyclass(
24        module = "nautilus_trader.core.nautilus_pyo3.portfolio",
25        from_py_object
26    )
27)]
28#[cfg_attr(
29    feature = "python",
30    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.portfolio")
31)]
32#[cfg_attr(
33    feature = "python",
34    expect(
35        clippy::unsafe_derive_deserialize,
36        reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
37    )
38)]
39#[expect(
40    clippy::struct_excessive_bools,
41    reason = "config fields mirror the existing Python and serialization surface"
42)]
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, bon::Builder)]
44#[builder(finish_fn(name = build_inner, vis = ""))]
45#[serde(deny_unknown_fields)]
46pub struct PortfolioConfig {
47    /// The type of prices used for portfolio calculations, such as unrealized PnLs.
48    /// If false (default), uses quote prices if available; otherwise, last trade prices
49    /// (or falls back to bar prices if `bar_updates` is true).
50    /// If true, uses mark prices.
51    #[serde(default)]
52    #[builder(default)]
53    pub use_mark_prices: bool,
54    /// The type of exchange rates used for portfolio calculations.
55    /// If false (default), uses quote prices.
56    /// If true, uses mark prices.
57    #[serde(default)]
58    #[builder(default)]
59    pub use_mark_xrates: bool,
60    /// If external bars should be considered for updating unrealized PnLs.
61    #[serde(default = "default_true")]
62    #[builder(default = true)]
63    pub bar_updates: bool,
64    /// If calculations should be converted into each account's base currency.
65    /// This setting is only effective for accounts with a specified base currency.
66    #[serde(default = "default_true")]
67    #[builder(default = true)]
68    pub convert_to_account_base_currency: bool,
69    /// The minimum interval (milliseconds) between logging account state events for the same account.
70    /// When set, account state updates will only be logged if this much time has passed since the last log.
71    /// Useful for HFT deployments to prevent excessive logging when account states change rapidly.
72    #[serde(default)]
73    pub min_account_state_logging_interval_ms: Option<u64>,
74    /// The interval (milliseconds) between portfolio snapshot emissions per account.
75    /// When set, a [`PortfolioSnapshot`] is emitted at this cadence while the
76    /// account holds at least one open position, carrying continuous
77    /// mark-to-market equity. When `None` (the default), no periodic snapshots
78    /// are emitted.
79    ///
80    /// [`PortfolioSnapshot`]: nautilus_model::events::PortfolioSnapshot
81    #[serde(default)]
82    pub snapshot_interval_ms: Option<u64>,
83    /// If debug mode is active (will provide extra debug logging).
84    #[serde(default)]
85    #[builder(default)]
86    pub debug: bool,
87}
88
89impl<S: portfolio_config_builder::IsComplete> PortfolioConfigBuilder<S> {
90    /// Validates and builds the [`PortfolioConfig`].
91    ///
92    /// # Errors
93    ///
94    /// Returns a [`ConfigError`] if any field fails validation
95    /// (see [`PortfolioConfig::validate`]).
96    pub fn build(self) -> ConfigResult<PortfolioConfig> {
97        let config = self.build_inner();
98        config.validate()?;
99        Ok(config)
100    }
101}
102
103impl PortfolioConfig {
104    /// Validates the portfolio configuration, collecting every field violation.
105    ///
106    /// # Errors
107    ///
108    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
109    /// invalid) if any field fails validation.
110    pub fn validate(&self) -> ConfigResult<()> {
111        let mut errors = ConfigErrorCollector::new();
112
113        for (field, value) in [
114            (
115                "min_account_state_logging_interval_ms",
116                self.min_account_state_logging_interval_ms,
117            ),
118            ("snapshot_interval_ms", self.snapshot_interval_ms),
119        ] {
120            if let Some(ms) = value {
121                errors.check(
122                    ms > 0,
123                    ConfigError::range(
124                        field,
125                        format!("must be a positive number of milliseconds, was {ms}"),
126                    ),
127                );
128            }
129        }
130
131        errors.into_result()
132    }
133}
134
135impl Default for PortfolioConfig {
136    fn default() -> Self {
137        Self::builder()
138            .build()
139            .expect("default `PortfolioConfig` should be valid")
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use rstest::rstest;
146
147    use super::*;
148
149    #[rstest]
150    fn test_default_config_is_valid() {
151        assert!(PortfolioConfig::builder().build().is_ok());
152    }
153
154    #[rstest]
155    fn test_zero_min_account_state_logging_interval_rejected() {
156        let result = PortfolioConfig::builder()
157            .min_account_state_logging_interval_ms(0)
158            .build();
159        assert!(
160            matches!(result, Err(ConfigError::Range { field, .. }) if field == "min_account_state_logging_interval_ms")
161        );
162    }
163
164    #[rstest]
165    fn test_zero_snapshot_interval_rejected() {
166        let result = PortfolioConfig::builder().snapshot_interval_ms(0).build();
167        assert!(
168            matches!(result, Err(ConfigError::Range { field, .. }) if field == "snapshot_interval_ms")
169        );
170    }
171
172    #[rstest]
173    fn test_positive_intervals_accepted() {
174        let result = PortfolioConfig::builder()
175            .min_account_state_logging_interval_ms(1_000)
176            .snapshot_interval_ms(5_000)
177            .build();
178        assert!(result.is_ok());
179    }
180
181    #[rstest]
182    fn test_multiple_violations_collected() {
183        let result = PortfolioConfig::builder()
184            .min_account_state_logging_interval_ms(0)
185            .snapshot_interval_ms(0)
186            .build();
187        let ConfigError::Multiple { errors } = result.unwrap_err() else {
188            panic!("expected ConfigError::Multiple");
189        };
190        assert_eq!(errors.len(), 2);
191    }
192}