Skip to main content

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}