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