nautilus_common/cache/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
16//! Position snapshot storage for the platform [`Cache`].
17//!
18//! Three mechanisms share the "position snapshot" name and live here together:
19//!
20//! - The NETTING archive ([`Cache::snapshot_position`]), which preserves each closed position
21//! cycle before its ID is reused, and backs cross-cycle realized PnL.
22//! - The durable correction boundary ([`Cache::snapshot_position_encoded`],
23//! [`Cache::restore_snapshot_blob`]), which produces and restores the encoded frames an event
24//! store anchors.
25//! - The routine state snapshot ([`Cache::snapshot_position_state`]), which writes position state
26//! to the backing database, defaulting to open positions.
27
28use std::{cell::OnceCell, str::FromStr};
29
30use ahash::AHashSet;
31use bytes::Bytes;
32use nautilus_core::{UUID4, UnixNanos};
33use nautilus_model::{
34 identifiers::{AccountId, InstrumentId, PositionId},
35 position::Position,
36 types::Money,
37};
38
39use super::Cache;
40
41/// Cache-owned reference to a snapshot blob.
42///
43/// The cache writes and later fetches the blob; external systems persist this opaque reference
44/// and may hash the bytes before recording a durable anchor.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct CacheSnapshotRef {
47 /// Opaque cache-owned snapshot location.
48 pub blob_ref: String,
49 /// Snapshot bytes stored under [`Self::blob_ref`].
50 pub blob: Bytes,
51}
52
53impl CacheSnapshotRef {
54 /// Creates a new [`CacheSnapshotRef`].
55 #[must_use]
56 pub fn new(blob_ref: impl Into<String>, blob: impl Into<Bytes>) -> Self {
57 Self {
58 blob_ref: blob_ref.into(),
59 blob: blob.into(),
60 }
61 }
62}
63
64/// One frame in a position's NETTING snapshot history.
65///
66/// A frame keeps the archived position and encodes it only when a consumer asks for the bytes,
67/// so a run with no durable snapshot sink never pays the encode on the order path. A frame
68/// restored from durable bytes keeps those exact bytes, since anchors record their content hash.
69#[derive(Debug)]
70pub(super) struct PositionSnapshotFrame {
71 position: Position,
72 encoded: OnceCell<Bytes>,
73}
74
75impl PositionSnapshotFrame {
76 fn new(position: Position, encoded: Option<Bytes>) -> Self {
77 Self {
78 position,
79 encoded: encoded.map_or_else(OnceCell::new, OnceCell::from),
80 }
81 }
82
83 fn encoded(&self) -> anyhow::Result<Bytes> {
84 if let Some(encoded) = self.encoded.get() {
85 return Ok(encoded.clone());
86 }
87
88 let encoded = Bytes::from(serde_json::to_vec(&self.position)?);
89 let _ = self.encoded.set(encoded.clone());
90
91 Ok(encoded)
92 }
93}
94
95impl Cache {
96 /// Creates a snapshot of the `position` by cloning it, assigning a new ID, and storing it
97 /// in the position snapshots.
98 ///
99 /// The copy excludes `replay_events` and `fill_voids`, which no snapshot consumer reads,
100 /// so snapshot size stays independent of the fills applied to the position ID. The copy
101 /// encodes only when a consumer asks for the bytes, so this call stays off the encode path
102 /// unless a backing database has to persist the frame.
103 ///
104 /// # Errors
105 ///
106 /// Returns an error if serializing or storing the position snapshot fails.
107 pub fn snapshot_position(&mut self, position: &Position) -> anyhow::Result<()> {
108 let (blob_ref, snapshot) = self.build_position_snapshot(position);
109
110 let encoded = if self.database.is_some() {
111 Some(self.persist_position_snapshot(&blob_ref, &snapshot)?)
112 } else {
113 None
114 };
115 self.store_position_snapshot(position.id, snapshot, encoded);
116
117 Ok(())
118 }
119
120 /// Creates a snapshot of the `position` and returns its encoded cache-owned reference.
121 ///
122 /// Behaves as [`Self::snapshot_position`] but encodes the frame eagerly, for callers that
123 /// record the bytes or their content hash against a durable anchor.
124 ///
125 /// # Errors
126 ///
127 /// Returns an error if serializing or storing the position snapshot fails.
128 pub fn snapshot_position_encoded(
129 &mut self,
130 position: &Position,
131 ) -> anyhow::Result<CacheSnapshotRef> {
132 let (blob_ref, snapshot) = self.build_position_snapshot(position);
133 let encoded = self.persist_position_snapshot(&blob_ref, &snapshot)?;
134
135 self.store_position_snapshot(position.id, snapshot, Some(encoded.clone()));
136
137 Ok(CacheSnapshotRef::new(blob_ref, encoded))
138 }
139
140 /// Replaces every NETTING archive frame held for `position` with the cycles a correction
141 /// rebuilt, worth `closed_cycles_pnl`.
142 ///
143 /// A correction that reaches an earlier cycle moves the boundaries the existing frames
144 /// describe, so they cannot be reconciled and are settled into one frame instead. Pass
145 /// `None` when the corrected history never goes flat, which leaves no archived cycle at all.
146 /// As with [`Self::purge_position`], the durable `cache://position-snapshots/...` entries
147 /// stay in general cache state.
148 ///
149 /// Requires `closed_cycles_pnl` to account for every frame held, since this removes all of
150 /// them. The position's replay log must therefore span every archived cycle for the ID. Any
151 /// future retention cap on the log has to preserve that at trim time, either by folding the
152 /// trimmed cycles' realized PnL into a baseline the rebuild adds to its banked total, or by
153 /// purging the frames those cycles produced in the same operation. Settling cannot detect the
154 /// shortfall, because frames carry no cycle identity to match against the retained log.
155 ///
156 /// Known limitation, shared with [`Self::purge_position`]: frame indices restart from zero,
157 /// so a later cycle can overwrite bytes an event store anchor already recorded, failing its
158 /// content-hash check on restore. Frames need an identity independent of their vector
159 /// position to fix it.
160 pub fn settle_position_snapshots(
161 &mut self,
162 position: &Position,
163 closed_cycles_pnl: Option<Money>,
164 ) {
165 self.position_snapshots.remove(&position.id);
166 self.bump_position_snapshot_revision(position.id);
167
168 if let Some(closed_cycles_pnl) = closed_cycles_pnl {
169 let (_, mut settled) = self.build_position_snapshot(position);
170 settled.realized_pnl = Some(closed_cycles_pnl);
171 self.store_position_snapshot(position.id, settled, None);
172 }
173 }
174
175 /// Records that the frames held for `position_id` were replaced rather than appended to.
176 ///
177 /// Consumers cache per-position aggregates keyed off the frame count, which settling and
178 /// purging can leave unchanged while the frames behind it differ.
179 pub(super) fn bump_position_snapshot_revision(&mut self, position_id: PositionId) {
180 *self
181 .position_snapshot_revisions
182 .entry(position_id)
183 .or_default() += 1;
184 }
185
186 fn build_position_snapshot(&self, position: &Position) -> (String, Position) {
187 let position_id = position.id;
188
189 let mut copied_position = position.clone_for_snapshot();
190 let new_id = format!("{}-{}", position_id.as_str(), UUID4::new());
191 copied_position.id = PositionId::new(new_id);
192
193 let blob_ref = format!(
194 "cache://position-snapshots/{}/{}",
195 position_id.as_str(),
196 self.position_snapshot_count(&position_id),
197 );
198
199 (blob_ref, copied_position)
200 }
201
202 fn persist_position_snapshot(
203 &mut self,
204 blob_ref: &str,
205 snapshot: &Position,
206 ) -> anyhow::Result<Bytes> {
207 let encoded = Bytes::from(serde_json::to_vec(snapshot)?);
208 self.add(blob_ref, encoded.clone())?;
209
210 Ok(encoded)
211 }
212
213 /// Stores the frame after any persist step, so a failed write does not advance the count.
214 fn store_position_snapshot(
215 &mut self,
216 position_id: PositionId,
217 snapshot: Position,
218 encoded: Option<Bytes>,
219 ) {
220 log::debug!("Snapshot {snapshot}");
221
222 self.position_snapshots
223 .entry(position_id)
224 .or_default()
225 .push(PositionSnapshotFrame::new(snapshot, encoded));
226 }
227
228 fn position_snapshot_frame(&self, blob_ref: &str) -> Option<&PositionSnapshotFrame> {
229 let (position_id, snapshot_index) = parse_position_snapshot_blob_ref(blob_ref).ok()?;
230
231 self.position_snapshots
232 .get(&position_id)
233 .and_then(|frames| frames.get(snapshot_index))
234 }
235
236 /// Loads the cache-owned snapshot blob stored under `blob_ref`.
237 ///
238 /// The cache first checks in-memory snapshot state. When the blob is not present and a
239 /// database adapter exists, the generic cache entries are loaded and checked for the same
240 /// opaque reference.
241 ///
242 /// # Errors
243 ///
244 /// Returns an error if loading generic cache entries from the backing database fails.
245 pub fn load_snapshot_blob(&mut self, blob_ref: &str) -> anyhow::Result<Option<Bytes>> {
246 if let Some(blob) = self.snapshot_blob(blob_ref) {
247 return Ok(Some(blob));
248 }
249
250 if self.database.is_some() {
251 self.cache_general()?;
252 }
253
254 Ok(self.snapshot_blob(blob_ref))
255 }
256
257 /// Restores the cache-owned snapshot blob stored under `blob_ref`.
258 ///
259 /// Only cache-owned `cache://position-snapshots/...` blobs are currently supported.
260 ///
261 /// # Errors
262 ///
263 /// Returns an error if the blob reference is unsupported, malformed, skips earlier
264 /// snapshot frames, conflicts with an existing frame, or does not decode to the expected
265 /// position snapshot.
266 pub fn restore_snapshot_blob(&mut self, blob_ref: &str, blob: Bytes) -> anyhow::Result<()> {
267 let (position_id, snapshot_index) = parse_position_snapshot_blob_ref(blob_ref)?;
268 let restored = decode_position_snapshot_blob(&position_id, blob.as_ref())?;
269
270 let frames = self.position_snapshots.entry(position_id).or_default();
271 match frames.get(snapshot_index) {
272 Some(existing) if existing.encoded()? == blob => {}
273 Some(_) => {
274 anyhow::bail!(
275 "position snapshot frame {snapshot_index} for {position_id} already exists with different bytes"
276 );
277 }
278 None if frames.len() == snapshot_index => {
279 frames.push(PositionSnapshotFrame::new(restored, Some(blob.clone())));
280 }
281 None => {
282 anyhow::bail!(
283 "position snapshot blob_ref {blob_ref} skips missing frame {}",
284 frames.len()
285 );
286 }
287 }
288
289 self.general.insert(blob_ref.to_string(), blob);
290 Ok(())
291 }
292
293 fn snapshot_blob(&self, blob_ref: &str) -> Option<Bytes> {
294 if let Some(blob) = self.general.get(blob_ref) {
295 return Some(blob.clone());
296 }
297
298 self.position_snapshot_frame(blob_ref)?
299 .encoded()
300 .inspect_err(|e| log::warn!("Failed to encode position snapshot {blob_ref}: {e}"))
301 .ok()
302 }
303
304 /// Creates a snapshot of the `position` state in the database.
305 ///
306 /// # Errors
307 ///
308 /// Returns an error if snapshotting the position state fails.
309 pub fn snapshot_position_state(
310 &mut self,
311 position: &Position,
312 ts_snapshot: UnixNanos,
313 unrealized_pnl: Option<Money>,
314 open_only: Option<bool>,
315 ) -> anyhow::Result<()> {
316 let open_only = open_only.unwrap_or(true);
317
318 if open_only && !position.is_open() {
319 return Ok(());
320 }
321
322 if let Some(database) = &mut self.database {
323 database
324 .snapshot_position_state(position, ts_snapshot, unrealized_pnl)
325 .map_err(|e| {
326 log::error!(
327 "Failed to snapshot position state for {}: {e:?}",
328 position.id
329 );
330 e
331 })?;
332 } else {
333 log::warn!(
334 "Cannot snapshot position state for {} (no database configured)",
335 position.id
336 );
337 }
338
339 Ok(())
340 }
341
342 /// Gets the serialized position snapshot frames for the `position_id`.
343 ///
344 /// Each element in the returned vector is one JSON-encoded [`Position`] snapshot,
345 /// in the order they were taken. Frames that fail to serialize are skipped with a warning.
346 #[must_use]
347 pub fn position_snapshot_bytes(&self, position_id: &PositionId) -> Option<Vec<Vec<u8>>> {
348 self.position_snapshots.get(position_id).map(|frames| {
349 frames
350 .iter()
351 .filter_map(|frame| match frame.encoded() {
352 Ok(encoded) => Some(encoded.to_vec()),
353 Err(e) => {
354 log::warn!("Failed to encode position snapshot: {e}");
355 None
356 }
357 })
358 .collect()
359 })
360 }
361
362 /// Returns the number of stored snapshot frames for the `position_id`.
363 ///
364 /// Returns `0` when no frames are stored. Does not allocate or copy frame bytes.
365 #[must_use]
366 pub fn position_snapshot_count(&self, position_id: &PositionId) -> usize {
367 self.position_snapshots.get(position_id).map_or(0, Vec::len)
368 }
369
370 /// Returns how many times the frames stored for the `position_id` were replaced.
371 ///
372 /// Pair this with [`Self::position_snapshot_count`] to detect frame changes: settling or
373 /// purging can replace the frames without moving the count, so the count alone is not
374 /// enough to tell whether cached per-position aggregates are still current.
375 #[must_use]
376 pub fn position_snapshot_revision(&self, position_id: &PositionId) -> u64 {
377 self.position_snapshot_revisions
378 .get(position_id)
379 .copied()
380 .unwrap_or(0)
381 }
382
383 /// Returns all position snapshots with the given optional filters.
384 ///
385 /// When `position_id` is `Some`, only snapshots for that position are returned.
386 /// When `account_id` is `Some`, snapshots are filtered to that account.
387 #[must_use]
388 pub fn position_snapshots(
389 &self,
390 position_id: Option<&PositionId>,
391 account_id: Option<&AccountId>,
392 ) -> Vec<Position> {
393 let frames: Box<dyn Iterator<Item = &PositionSnapshotFrame> + '_> = match position_id {
394 Some(pid) => match self.position_snapshots.get(pid) {
395 Some(v) => Box::new(v.iter()),
396 None => Box::new(std::iter::empty()),
397 },
398 None => Box::new(self.position_snapshots.values().flat_map(|v| v.iter())),
399 };
400
401 let mut results: Vec<Position> = frames.map(|frame| frame.position.clone()).collect();
402
403 if let Some(aid) = account_id {
404 results.retain(|p| p.account_id == *aid);
405 }
406
407 results
408 }
409
410 /// Returns position snapshots for `position_id` starting from the `skip`th frame.
411 ///
412 /// Use this to read only newly appended snapshots when the caller already processed
413 /// earlier frames. Returns an empty vector when at most `skip` frames are stored.
414 #[must_use]
415 pub fn position_snapshots_from(&self, position_id: &PositionId, skip: usize) -> Vec<Position> {
416 let Some(frames) = self.position_snapshots.get(position_id) else {
417 return Vec::new();
418 };
419
420 frames
421 .iter()
422 .skip(skip)
423 .map(|frame| frame.position.clone())
424 .collect()
425 }
426
427 /// Gets position snapshot IDs for the `instrument_id`.
428 #[must_use]
429 pub fn position_snapshot_ids(&self, instrument_id: &InstrumentId) -> AHashSet<PositionId> {
430 self.position_snapshots
431 .keys()
432 .filter(|position_id| {
433 self.positions
434 .get(position_id)
435 .is_some_and(|position| position.borrow().instrument_id == *instrument_id)
436 })
437 .copied()
438 .collect()
439 }
440}
441
442fn parse_position_snapshot_blob_ref(blob_ref: &str) -> anyhow::Result<(PositionId, usize)> {
443 let Some(rest) = blob_ref.strip_prefix("cache://position-snapshots/") else {
444 anyhow::bail!("unsupported cache snapshot blob_ref {blob_ref}");
445 };
446
447 let Some((position_id, snapshot_index)) = rest.rsplit_once('/') else {
448 anyhow::bail!("malformed position snapshot blob_ref {blob_ref}");
449 };
450
451 if position_id.is_empty() {
452 anyhow::bail!("position snapshot blob_ref {blob_ref} has empty position id");
453 }
454
455 let snapshot_index = snapshot_index.parse::<usize>().map_err(|e| {
456 anyhow::anyhow!("position snapshot blob_ref {blob_ref} has invalid frame index: {e}")
457 })?;
458
459 Ok((PositionId::new(position_id), snapshot_index))
460}
461
462fn decode_position_snapshot_blob(
463 position_id: &PositionId,
464 blob: &[u8],
465) -> anyhow::Result<Position> {
466 let snapshot = serde_json::from_slice::<Position>(blob)?;
467 let expected_prefix = format!("{}-", position_id.as_str());
468
469 let Some(snapshot_uuid) = snapshot.id.as_str().strip_prefix(&expected_prefix) else {
470 anyhow::bail!(
471 "position snapshot id {} does not match blob_ref position {position_id}",
472 snapshot.id
473 );
474 };
475
476 if UUID4::from_str(snapshot_uuid).is_err() {
477 anyhow::bail!(
478 "position snapshot id {} does not match blob_ref position {position_id}",
479 snapshot.id
480 );
481 }
482
483 Ok(snapshot)
484}