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();
190        let new_id = format!("{}-{}", position_id.as_str(), UUID4::new());
191        copied_position.id = PositionId::new(new_id);
192        copied_position.replay_events.clear();
193        copied_position.fill_voids.clear();
194
195        let blob_ref = format!(
196            "cache://position-snapshots/{}/{}",
197            position_id.as_str(),
198            self.position_snapshot_count(&position_id),
199        );
200
201        (blob_ref, copied_position)
202    }
203
204    fn persist_position_snapshot(
205        &mut self,
206        blob_ref: &str,
207        snapshot: &Position,
208    ) -> anyhow::Result<Bytes> {
209        let encoded = Bytes::from(serde_json::to_vec(snapshot)?);
210        self.add(blob_ref, encoded.clone())?;
211
212        Ok(encoded)
213    }
214
215    /// Stores the frame after any persist step, so a failed write does not advance the count.
216    fn store_position_snapshot(
217        &mut self,
218        position_id: PositionId,
219        snapshot: Position,
220        encoded: Option<Bytes>,
221    ) {
222        log::debug!("Snapshot {snapshot}");
223
224        self.position_snapshots
225            .entry(position_id)
226            .or_default()
227            .push(PositionSnapshotFrame::new(snapshot, encoded));
228    }
229
230    fn position_snapshot_frame(&self, blob_ref: &str) -> Option<&PositionSnapshotFrame> {
231        let (position_id, snapshot_index) = parse_position_snapshot_blob_ref(blob_ref).ok()?;
232
233        self.position_snapshots
234            .get(&position_id)
235            .and_then(|frames| frames.get(snapshot_index))
236    }
237
238    /// Loads the cache-owned snapshot blob stored under `blob_ref`.
239    ///
240    /// The cache first checks in-memory snapshot state. When the blob is not present and a
241    /// database adapter exists, the generic cache entries are loaded and checked for the same
242    /// opaque reference.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if loading generic cache entries from the backing database fails.
247    pub fn load_snapshot_blob(&mut self, blob_ref: &str) -> anyhow::Result<Option<Bytes>> {
248        if let Some(blob) = self.snapshot_blob(blob_ref) {
249            return Ok(Some(blob));
250        }
251
252        if self.database.is_some() {
253            self.cache_general()?;
254        }
255
256        Ok(self.snapshot_blob(blob_ref))
257    }
258
259    /// Restores the cache-owned snapshot blob stored under `blob_ref`.
260    ///
261    /// Only cache-owned `cache://position-snapshots/...` blobs are currently supported.
262    ///
263    /// # Errors
264    ///
265    /// Returns an error if the blob reference is unsupported, malformed, skips earlier
266    /// snapshot frames, conflicts with an existing frame, or does not decode to the expected
267    /// position snapshot.
268    pub fn restore_snapshot_blob(&mut self, blob_ref: &str, blob: Bytes) -> anyhow::Result<()> {
269        let (position_id, snapshot_index) = parse_position_snapshot_blob_ref(blob_ref)?;
270        let restored = decode_position_snapshot_blob(&position_id, blob.as_ref())?;
271
272        let frames = self.position_snapshots.entry(position_id).or_default();
273        match frames.get(snapshot_index) {
274            Some(existing) if existing.encoded()? == blob => {}
275            Some(_) => {
276                anyhow::bail!(
277                    "position snapshot frame {snapshot_index} for {position_id} already exists with different bytes"
278                );
279            }
280            None if frames.len() == snapshot_index => {
281                frames.push(PositionSnapshotFrame::new(restored, Some(blob.clone())));
282            }
283            None => {
284                anyhow::bail!(
285                    "position snapshot blob_ref {blob_ref} skips missing frame {}",
286                    frames.len()
287                );
288            }
289        }
290
291        self.general.insert(blob_ref.to_string(), blob);
292        Ok(())
293    }
294
295    fn snapshot_blob(&self, blob_ref: &str) -> Option<Bytes> {
296        if let Some(blob) = self.general.get(blob_ref) {
297            return Some(blob.clone());
298        }
299
300        self.position_snapshot_frame(blob_ref)?
301            .encoded()
302            .inspect_err(|e| log::warn!("Failed to encode position snapshot {blob_ref}: {e}"))
303            .ok()
304    }
305
306    /// Creates a snapshot of the `position` state in the database.
307    ///
308    /// # Errors
309    ///
310    /// Returns an error if snapshotting the position state fails.
311    pub fn snapshot_position_state(
312        &mut self,
313        position: &Position,
314        ts_snapshot: UnixNanos,
315        unrealized_pnl: Option<Money>,
316        open_only: Option<bool>,
317    ) -> anyhow::Result<()> {
318        let open_only = open_only.unwrap_or(true);
319
320        if open_only && !position.is_open() {
321            return Ok(());
322        }
323
324        if let Some(database) = &mut self.database {
325            database
326                .snapshot_position_state(position, ts_snapshot, unrealized_pnl)
327                .map_err(|e| {
328                    log::error!(
329                        "Failed to snapshot position state for {}: {e:?}",
330                        position.id
331                    );
332                    e
333                })?;
334        } else {
335            log::warn!(
336                "Cannot snapshot position state for {} (no database configured)",
337                position.id
338            );
339        }
340
341        Ok(())
342    }
343
344    /// Gets the serialized position snapshot frames for the `position_id`.
345    ///
346    /// Each element in the returned vector is one JSON-encoded [`Position`] snapshot,
347    /// in the order they were taken. Frames that fail to serialize are skipped with a warning.
348    #[must_use]
349    pub fn position_snapshot_bytes(&self, position_id: &PositionId) -> Option<Vec<Vec<u8>>> {
350        self.position_snapshots.get(position_id).map(|frames| {
351            frames
352                .iter()
353                .filter_map(|frame| match frame.encoded() {
354                    Ok(encoded) => Some(encoded.to_vec()),
355                    Err(e) => {
356                        log::warn!("Failed to encode position snapshot: {e}");
357                        None
358                    }
359                })
360                .collect()
361        })
362    }
363
364    /// Returns the number of stored snapshot frames for the `position_id`.
365    ///
366    /// Returns `0` when no frames are stored. Does not allocate or copy frame bytes.
367    #[must_use]
368    pub fn position_snapshot_count(&self, position_id: &PositionId) -> usize {
369        self.position_snapshots.get(position_id).map_or(0, Vec::len)
370    }
371
372    /// Returns how many times the frames stored for the `position_id` were replaced.
373    ///
374    /// Pair this with [`Self::position_snapshot_count`] to detect frame changes: settling or
375    /// purging can replace the frames without moving the count, so the count alone is not
376    /// enough to tell whether cached per-position aggregates are still current.
377    #[must_use]
378    pub fn position_snapshot_revision(&self, position_id: &PositionId) -> u64 {
379        self.position_snapshot_revisions
380            .get(position_id)
381            .copied()
382            .unwrap_or(0)
383    }
384
385    /// Returns all position snapshots with the given optional filters.
386    ///
387    /// When `position_id` is `Some`, only snapshots for that position are returned.
388    /// When `account_id` is `Some`, snapshots are filtered to that account.
389    #[must_use]
390    pub fn position_snapshots(
391        &self,
392        position_id: Option<&PositionId>,
393        account_id: Option<&AccountId>,
394    ) -> Vec<Position> {
395        let frames: Box<dyn Iterator<Item = &PositionSnapshotFrame> + '_> = match position_id {
396            Some(pid) => match self.position_snapshots.get(pid) {
397                Some(v) => Box::new(v.iter()),
398                None => Box::new(std::iter::empty()),
399            },
400            None => Box::new(self.position_snapshots.values().flat_map(|v| v.iter())),
401        };
402
403        let mut results: Vec<Position> = frames.map(|frame| frame.position.clone()).collect();
404
405        if let Some(aid) = account_id {
406            results.retain(|p| p.account_id == *aid);
407        }
408
409        results
410    }
411
412    /// Returns position snapshots for `position_id` starting from the `skip`th frame.
413    ///
414    /// Use this to read only newly appended snapshots when the caller already processed
415    /// earlier frames. Returns an empty vector when at most `skip` frames are stored.
416    #[must_use]
417    pub fn position_snapshots_from(&self, position_id: &PositionId, skip: usize) -> Vec<Position> {
418        let Some(frames) = self.position_snapshots.get(position_id) else {
419            return Vec::new();
420        };
421
422        frames
423            .iter()
424            .skip(skip)
425            .map(|frame| frame.position.clone())
426            .collect()
427    }
428
429    /// Gets position snapshot IDs for the `instrument_id`.
430    #[must_use]
431    pub fn position_snapshot_ids(&self, instrument_id: &InstrumentId) -> AHashSet<PositionId> {
432        // Get snapshot position IDs that match the instrument
433        let mut result = AHashSet::new();
434
435        for (position_id, _) in &self.position_snapshots {
436            // Check if this position is for the requested instrument
437            if let Some(position_cell) = self.positions.get(position_id)
438                && position_cell.borrow().instrument_id == *instrument_id
439            {
440                result.insert(*position_id);
441            }
442        }
443        result
444    }
445}
446
447fn parse_position_snapshot_blob_ref(blob_ref: &str) -> anyhow::Result<(PositionId, usize)> {
448    let Some(rest) = blob_ref.strip_prefix("cache://position-snapshots/") else {
449        anyhow::bail!("unsupported cache snapshot blob_ref {blob_ref}");
450    };
451
452    let Some((position_id, snapshot_index)) = rest.rsplit_once('/') else {
453        anyhow::bail!("malformed position snapshot blob_ref {blob_ref}");
454    };
455
456    if position_id.is_empty() {
457        anyhow::bail!("position snapshot blob_ref {blob_ref} has empty position id");
458    }
459
460    let snapshot_index = snapshot_index.parse::<usize>().map_err(|e| {
461        anyhow::anyhow!("position snapshot blob_ref {blob_ref} has invalid frame index: {e}")
462    })?;
463
464    Ok((PositionId::new(position_id), snapshot_index))
465}
466
467fn decode_position_snapshot_blob(
468    position_id: &PositionId,
469    blob: &[u8],
470) -> anyhow::Result<Position> {
471    let snapshot = serde_json::from_slice::<Position>(blob)?;
472    let expected_prefix = format!("{}-", position_id.as_str());
473
474    let Some(snapshot_uuid) = snapshot.id.as_str().strip_prefix(&expected_prefix) else {
475        anyhow::bail!(
476            "position snapshot id {} does not match blob_ref position {position_id}",
477            snapshot.id
478        );
479    };
480
481    if UUID4::from_str(snapshot_uuid).is_err() {
482        anyhow::bail!(
483            "position snapshot id {} does not match blob_ref position {position_id}",
484            snapshot.id
485        );
486    }
487
488    Ok(snapshot)
489}