use std::{cell::OnceCell, str::FromStr};
use ahash::AHashSet;
use bytes::Bytes;
use nautilus_core::{UUID4, UnixNanos};
use nautilus_model::{
identifiers::{AccountId, InstrumentId, PositionId},
position::Position,
types::Money,
};
use super::Cache;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CacheSnapshotRef {
pub blob_ref: String,
pub blob: Bytes,
}
impl CacheSnapshotRef {
#[must_use]
pub fn new(blob_ref: impl Into<String>, blob: impl Into<Bytes>) -> Self {
Self {
blob_ref: blob_ref.into(),
blob: blob.into(),
}
}
}
#[derive(Debug)]
pub(super) struct PositionSnapshotFrame {
position: Position,
encoded: OnceCell<Bytes>,
}
impl PositionSnapshotFrame {
fn new(position: Position, encoded: Option<Bytes>) -> Self {
Self {
position,
encoded: encoded.map_or_else(OnceCell::new, OnceCell::from),
}
}
fn encoded(&self) -> anyhow::Result<Bytes> {
if let Some(encoded) = self.encoded.get() {
return Ok(encoded.clone());
}
let encoded = Bytes::from(serde_json::to_vec(&self.position)?);
let _ = self.encoded.set(encoded.clone());
Ok(encoded)
}
}
impl Cache {
pub fn snapshot_position(&mut self, position: &Position) -> anyhow::Result<()> {
let (blob_ref, snapshot) = self.build_position_snapshot(position);
let encoded = if self.database.is_some() {
Some(self.persist_position_snapshot(&blob_ref, &snapshot)?)
} else {
None
};
self.store_position_snapshot(position.id, snapshot, encoded);
Ok(())
}
pub fn snapshot_position_encoded(
&mut self,
position: &Position,
) -> anyhow::Result<CacheSnapshotRef> {
let (blob_ref, snapshot) = self.build_position_snapshot(position);
let encoded = self.persist_position_snapshot(&blob_ref, &snapshot)?;
self.store_position_snapshot(position.id, snapshot, Some(encoded.clone()));
Ok(CacheSnapshotRef::new(blob_ref, encoded))
}
pub fn settle_position_snapshots(
&mut self,
position: &Position,
closed_cycles_pnl: Option<Money>,
) {
self.position_snapshots.remove(&position.id);
self.bump_position_snapshot_revision(position.id);
if let Some(closed_cycles_pnl) = closed_cycles_pnl {
let (_, mut settled) = self.build_position_snapshot(position);
settled.realized_pnl = Some(closed_cycles_pnl);
self.store_position_snapshot(position.id, settled, None);
}
}
pub(super) fn bump_position_snapshot_revision(&mut self, position_id: PositionId) {
*self
.position_snapshot_revisions
.entry(position_id)
.or_default() += 1;
}
fn build_position_snapshot(&self, position: &Position) -> (String, Position) {
let position_id = position.id;
let mut copied_position = position.clone();
let new_id = format!("{}-{}", position_id.as_str(), UUID4::new());
copied_position.id = PositionId::new(new_id);
copied_position.replay_events.clear();
copied_position.fill_voids.clear();
let blob_ref = format!(
"cache://position-snapshots/{}/{}",
position_id.as_str(),
self.position_snapshot_count(&position_id),
);
(blob_ref, copied_position)
}
fn persist_position_snapshot(
&mut self,
blob_ref: &str,
snapshot: &Position,
) -> anyhow::Result<Bytes> {
let encoded = Bytes::from(serde_json::to_vec(snapshot)?);
self.add(blob_ref, encoded.clone())?;
Ok(encoded)
}
fn store_position_snapshot(
&mut self,
position_id: PositionId,
snapshot: Position,
encoded: Option<Bytes>,
) {
log::debug!("Snapshot {snapshot}");
self.position_snapshots
.entry(position_id)
.or_default()
.push(PositionSnapshotFrame::new(snapshot, encoded));
}
fn position_snapshot_frame(&self, blob_ref: &str) -> Option<&PositionSnapshotFrame> {
let (position_id, snapshot_index) = parse_position_snapshot_blob_ref(blob_ref).ok()?;
self.position_snapshots
.get(&position_id)
.and_then(|frames| frames.get(snapshot_index))
}
pub fn load_snapshot_blob(&mut self, blob_ref: &str) -> anyhow::Result<Option<Bytes>> {
if let Some(blob) = self.snapshot_blob(blob_ref) {
return Ok(Some(blob));
}
if self.database.is_some() {
self.cache_general()?;
}
Ok(self.snapshot_blob(blob_ref))
}
pub fn restore_snapshot_blob(&mut self, blob_ref: &str, blob: Bytes) -> anyhow::Result<()> {
let (position_id, snapshot_index) = parse_position_snapshot_blob_ref(blob_ref)?;
let restored = decode_position_snapshot_blob(&position_id, blob.as_ref())?;
let frames = self.position_snapshots.entry(position_id).or_default();
match frames.get(snapshot_index) {
Some(existing) if existing.encoded()? == blob => {}
Some(_) => {
anyhow::bail!(
"position snapshot frame {snapshot_index} for {position_id} already exists with different bytes"
);
}
None if frames.len() == snapshot_index => {
frames.push(PositionSnapshotFrame::new(restored, Some(blob.clone())));
}
None => {
anyhow::bail!(
"position snapshot blob_ref {blob_ref} skips missing frame {}",
frames.len()
);
}
}
self.general.insert(blob_ref.to_string(), blob);
Ok(())
}
fn snapshot_blob(&self, blob_ref: &str) -> Option<Bytes> {
if let Some(blob) = self.general.get(blob_ref) {
return Some(blob.clone());
}
self.position_snapshot_frame(blob_ref)?
.encoded()
.inspect_err(|e| log::warn!("Failed to encode position snapshot {blob_ref}: {e}"))
.ok()
}
pub fn snapshot_position_state(
&mut self,
position: &Position,
ts_snapshot: UnixNanos,
unrealized_pnl: Option<Money>,
open_only: Option<bool>,
) -> anyhow::Result<()> {
let open_only = open_only.unwrap_or(true);
if open_only && !position.is_open() {
return Ok(());
}
if let Some(database) = &mut self.database {
database
.snapshot_position_state(position, ts_snapshot, unrealized_pnl)
.map_err(|e| {
log::error!(
"Failed to snapshot position state for {}: {e:?}",
position.id
);
e
})?;
} else {
log::warn!(
"Cannot snapshot position state for {} (no database configured)",
position.id
);
}
Ok(())
}
#[must_use]
pub fn position_snapshot_bytes(&self, position_id: &PositionId) -> Option<Vec<Vec<u8>>> {
self.position_snapshots.get(position_id).map(|frames| {
frames
.iter()
.filter_map(|frame| match frame.encoded() {
Ok(encoded) => Some(encoded.to_vec()),
Err(e) => {
log::warn!("Failed to encode position snapshot: {e}");
None
}
})
.collect()
})
}
#[must_use]
pub fn position_snapshot_count(&self, position_id: &PositionId) -> usize {
self.position_snapshots.get(position_id).map_or(0, Vec::len)
}
#[must_use]
pub fn position_snapshot_revision(&self, position_id: &PositionId) -> u64 {
self.position_snapshot_revisions
.get(position_id)
.copied()
.unwrap_or(0)
}
#[must_use]
pub fn position_snapshots(
&self,
position_id: Option<&PositionId>,
account_id: Option<&AccountId>,
) -> Vec<Position> {
let frames: Box<dyn Iterator<Item = &PositionSnapshotFrame> + '_> = match position_id {
Some(pid) => match self.position_snapshots.get(pid) {
Some(v) => Box::new(v.iter()),
None => Box::new(std::iter::empty()),
},
None => Box::new(self.position_snapshots.values().flat_map(|v| v.iter())),
};
let mut results: Vec<Position> = frames.map(|frame| frame.position.clone()).collect();
if let Some(aid) = account_id {
results.retain(|p| p.account_id == *aid);
}
results
}
#[must_use]
pub fn position_snapshots_from(&self, position_id: &PositionId, skip: usize) -> Vec<Position> {
let Some(frames) = self.position_snapshots.get(position_id) else {
return Vec::new();
};
frames
.iter()
.skip(skip)
.map(|frame| frame.position.clone())
.collect()
}
#[must_use]
pub fn position_snapshot_ids(&self, instrument_id: &InstrumentId) -> AHashSet<PositionId> {
let mut result = AHashSet::new();
for (position_id, _) in &self.position_snapshots {
if let Some(position_cell) = self.positions.get(position_id)
&& position_cell.borrow().instrument_id == *instrument_id
{
result.insert(*position_id);
}
}
result
}
}
fn parse_position_snapshot_blob_ref(blob_ref: &str) -> anyhow::Result<(PositionId, usize)> {
let Some(rest) = blob_ref.strip_prefix("cache://position-snapshots/") else {
anyhow::bail!("unsupported cache snapshot blob_ref {blob_ref}");
};
let Some((position_id, snapshot_index)) = rest.rsplit_once('/') else {
anyhow::bail!("malformed position snapshot blob_ref {blob_ref}");
};
if position_id.is_empty() {
anyhow::bail!("position snapshot blob_ref {blob_ref} has empty position id");
}
let snapshot_index = snapshot_index.parse::<usize>().map_err(|e| {
anyhow::anyhow!("position snapshot blob_ref {blob_ref} has invalid frame index: {e}")
})?;
Ok((PositionId::new(position_id), snapshot_index))
}
fn decode_position_snapshot_blob(
position_id: &PositionId,
blob: &[u8],
) -> anyhow::Result<Position> {
let snapshot = serde_json::from_slice::<Position>(blob)?;
let expected_prefix = format!("{}-", position_id.as_str());
let Some(snapshot_uuid) = snapshot.id.as_str().strip_prefix(&expected_prefix) else {
anyhow::bail!(
"position snapshot id {} does not match blob_ref position {position_id}",
snapshot.id
);
};
if UUID4::from_str(snapshot_uuid).is_err() {
anyhow::bail!(
"position snapshot id {} does not match blob_ref position {position_id}",
snapshot.id
);
}
Ok(snapshot)
}