simulator-api 0.27.0

Wire-protocol types for the Solana simulator backtest WebSocket API
Documentation
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};

use crate::SessionId;

/// Response body of `GET /usage`.
///
/// `since` is the start of the UTC day it falls in, so it may precede the
/// requested value.
#[derive(Debug, Serialize, Deserialize)]
pub struct UsageReport {
    pub api_key_name: String,
    pub since: DateTime<Utc>,
    pub until: DateTime<Utc>,
    sessions: SessionCounts,
    compute: ComputeTotals,
    /// One bucket per UTC day in `(since, until]`, ordered and gapless: an idle
    /// day is present with zero counts, and a session ending on a midnight
    /// belongs to the bucket that midnight closes. The series sums to
    /// `sessions` and `compute`; it is empty only from a server predating it.
    #[serde(default)]
    pub daily: Vec<UsageBucket>,
    /// Present only for keys with the `build_bundle` feature.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bundle_builds: Option<BundleBuildUsage>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pricing: Option<UsagePricing>,
}

impl UsageReport {
    /// Folds the totals from `daily`, which is the only way to set them.
    pub fn new(
        api_key_name: String,
        since: DateTime<Utc>,
        until: DateTime<Utc>,
        daily: Vec<UsageBucket>,
        bundle_builds: Option<BundleBuildUsage>,
        pricing: Option<UsagePricing>,
    ) -> Self {
        let (sessions, compute) = daily.iter().fold(
            (SessionCounts::default(), ComputeTotals::default()),
            |(sessions, compute), bucket| {
                (
                    sessions.saturating_add(bucket.sessions),
                    compute.saturating_add(bucket.compute),
                )
            },
        );
        Self {
            api_key_name,
            since,
            until,
            sessions,
            compute,
            daily,
            bundle_builds,
            pricing,
        }
    }

    /// The sum over `daily`, except on a response from a server predating it,
    /// where it is the total that server reported for the whole window.
    pub fn sessions(&self) -> SessionCounts {
        self.sessions
    }

    /// The sum over `daily`, except on a response from a server predating it,
    /// where it is the total that server reported for the whole window.
    pub fn compute(&self) -> ComputeTotals {
        self.compute
    }
}

/// What one whole [`UsageBucket`] spans.
pub const BUCKET_SPAN: Duration = Duration::days(1);

/// One UTC day of a [`UsageReport`], covering `(start, end]`. `end` is the
/// following midnight, except for a last bucket that ends at the report's
/// `until`.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct UsageBucket {
    pub start: DateTime<Utc>,
    pub end: DateTime<Utc>,
    pub sessions: SessionCounts,
    pub compute: ComputeTotals,
}

impl UsageBucket {
    /// Only the last bucket of a report can be partial.
    pub fn is_partial(&self) -> bool {
        self.end.signed_duration_since(self.start) < BUCKET_SPAN
    }

    /// A day with no activity, kept only to leave the series gapless.
    pub fn is_empty(&self) -> bool {
        self.sessions == SessionCounts::default() && self.compute == ComputeTotals::default()
    }
}

/// Response body of `GET /usage/sessions`.
///
/// Its own endpoint because it is the one part of a usage report that grows
/// with usage rather than with the window.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct SessionUsagePage {
    /// The window actually served, `since` snapped back to its UTC midnight.
    /// Pass these back with `next_cursor` so every page resolves the same
    /// window rather than one drifting with `now`.
    pub since: DateTime<Utc>,
    pub until: DateTime<Utc>,
    /// Every session that ended in the window, in no promised order: the pages
    /// together are the guarantee, not their sequence.
    pub sessions: Vec<SessionUsage>,
    /// Opaque continuation token, `None` once the window holds nothing further.
    /// Pass it back as `cursor` to read the next page.
    #[serde(default)]
    pub next_cursor: Option<String>,
}

/// One session that ran under the key, as its terminal event reported it.
///
/// Built from the same fire-and-forget metric points as the rest of the report,
/// so a point dropped under back-pressure takes its session out of the list.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct SessionUsage {
    pub session_id: SessionId,
    pub started_at: DateTime<Utc>,
    pub ended_at: DateTime<Utc>,
    pub executed_slot_count: u64,
    pub session_duration_ms: u64,
}

/// The billing rates a report's totals are priced at. Served by the manager so
/// the rates live in one place rather than in every client.
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
pub struct UsagePricing {
    pub dollars_per_slot: f64,
    pub dollars_per_minute: f64,
    /// A run slower than this per slot is billed by wall time instead.
    pub max_ms_per_slot: u64,
}

impl UsagePricing {
    /// Dollars for `slots` executed over `duration_ms` of wall time.
    pub fn cost(&self, slots: u64, duration_ms: u64) -> f64 {
        if slots.saturating_mul(self.max_ms_per_slot) >= duration_ms {
            slots as f64 * self.dollars_per_slot
        } else {
            duration_ms as f64 / 1000.0 / 60.0 * self.dollars_per_minute
        }
    }
}

/// Bundle-build usage for the window.
#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
pub struct BundleBuildUsage {
    pub requested: u64,
}

/// Point counts by outcome, not session-deduplicated, so the three need not
/// balance: a pre-start failure has no `started`, a cross-manager handoff
/// re-emits it. Billing uses the compute totals, which derive only from the
/// terminal points.
///
/// Within a [`UsageBucket`], `started` counts sessions that began that day and
/// `completed`/`failed` those that ended that day.
#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
pub struct SessionCounts {
    pub started: u64,
    pub completed: u64,
    pub failed: u64,
}

/// A session contributes wholly to the window — or [`UsageBucket`] — its run
/// ended in, even when it ran across the boundary.
#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
pub struct ComputeTotals {
    pub executed_slot_count: u64,
    pub session_duration_ms: u64,
}

impl SessionCounts {
    pub fn saturating_add(self, other: Self) -> Self {
        Self {
            started: self.started.saturating_add(other.started),
            completed: self.completed.saturating_add(other.completed),
            failed: self.failed.saturating_add(other.failed),
        }
    }
}

impl ComputeTotals {
    pub fn saturating_add(self, other: Self) -> Self {
        Self {
            executed_slot_count: self
                .executed_slot_count
                .saturating_add(other.executed_slot_count),
            session_duration_ms: self
                .session_duration_ms
                .saturating_add(other.session_duration_ms),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn report(bundle_builds: Option<BundleBuildUsage>) -> UsageReport {
        UsageReport::new(
            "k".to_string(),
            "2026-01-01T00:00:00Z".parse().unwrap(),
            "2026-01-02T00:00:00Z".parse().unwrap(),
            vec![UsageBucket {
                start: "2026-01-01T00:00:00Z".parse().unwrap(),
                end: "2026-01-02T00:00:00Z".parse().unwrap(),
                sessions: SessionCounts::default(),
                compute: ComputeTotals::default(),
            }],
            bundle_builds,
            None,
        )
    }

    fn session() -> SessionUsage {
        SessionUsage {
            session_id: "backtest_0198f3a1c2d34e5f8a9b0c1d2e3f4a5b".parse().unwrap(),
            started_at: "2026-01-01T09:14:02Z".parse().unwrap(),
            ended_at: "2026-01-01T09:18:41Z".parse().unwrap(),
            executed_slot_count: 3_100,
            session_duration_ms: 279_000,
        }
    }

    fn bucket(day: &str, sessions: SessionCounts, compute: ComputeTotals) -> UsageBucket {
        let start: DateTime<Utc> = format!("2026-01-{day}T00:00:00Z").parse().unwrap();
        UsageBucket {
            start,
            end: start + BUCKET_SPAN,
            sessions,
            compute,
        }
    }

    #[test]
    fn new_sums_the_totals_from_the_buckets() {
        let report = UsageReport::new(
            "k".to_string(),
            "2026-01-01T00:00:00Z".parse().unwrap(),
            "2026-01-03T00:00:00Z".parse().unwrap(),
            vec![
                bucket(
                    "01",
                    SessionCounts {
                        started: 1,
                        completed: 1,
                        failed: 0,
                    },
                    ComputeTotals {
                        executed_slot_count: 5,
                        session_duration_ms: 9,
                    },
                ),
                bucket(
                    "02",
                    SessionCounts {
                        started: 2,
                        completed: 1,
                        failed: 1,
                    },
                    ComputeTotals {
                        executed_slot_count: 7,
                        session_duration_ms: 3,
                    },
                ),
            ],
            None,
            None,
        );
        assert_eq!(
            report.sessions(),
            SessionCounts {
                started: 3,
                completed: 2,
                failed: 1
            }
        );
        assert_eq!(
            report.compute(),
            ComputeTotals {
                executed_slot_count: 12,
                session_duration_ms: 12
            }
        );
    }

    #[test]
    fn new_totals_of_no_buckets_are_zero() {
        let report = UsageReport::new(
            "k".to_string(),
            "2026-01-01T00:00:00Z".parse().unwrap(),
            "2026-01-01T00:00:00Z".parse().unwrap(),
            vec![],
            None,
            None,
        );
        assert_eq!(report.sessions(), SessionCounts::default());
        assert_eq!(report.compute(), ComputeTotals::default());
    }

    #[test]
    fn usage_report_absent_bundle_builds_round_trips_to_none() {
        let json = serde_json::to_string(&report(None)).unwrap();
        assert!(!json.contains("bundle_builds"));
        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.bundle_builds, None);
    }

    #[test]
    fn usage_report_absent_daily_round_trips_to_empty() {
        let json = r#"{"api_key_name":"k","since":"2026-01-01T00:00:00Z","until":"2026-01-02T00:00:00Z","sessions":{"started":1,"completed":1,"failed":0},"compute":{"executed_slot_count":5,"session_duration_ms":9}}"#;
        let parsed: UsageReport = serde_json::from_str(json).unwrap();
        assert!(parsed.daily.is_empty());
        // The totals stay on the wire so an empty `daily` does not read as no usage.
        assert_eq!(
            parsed.sessions(),
            SessionCounts {
                started: 1,
                completed: 1,
                failed: 0
            }
        );
        assert_eq!(
            parsed.compute(),
            ComputeTotals {
                executed_slot_count: 5,
                session_duration_ms: 9
            }
        );
    }

    #[test]
    fn usage_report_daily_round_trips() {
        let json = serde_json::to_string(&report(None)).unwrap();
        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.daily, report(None).daily);
    }

    /// The prefixed form is what anything taking a session id accepts.
    #[test]
    fn usage_sessions_round_trip_with_prefixed_ids() {
        let json = serde_json::to_string(&session_page()).unwrap();
        assert!(json.contains("\"backtest_0198f3a1c2d34e5f8a9b0c1d2e3f4a5b\""));
        let parsed: SessionUsagePage = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, session_page());
    }

    fn session_page() -> SessionUsagePage {
        SessionUsagePage {
            since: "2026-01-01T00:00:00Z".parse().unwrap(),
            until: "2026-01-02T00:00:00Z".parse().unwrap(),
            sessions: vec![session()],
            next_cursor: None,
        }
    }

    /// An omitted cursor means "nothing further", not a parse failure.
    #[test]
    fn usage_sessions_absent_cursor_round_trips_to_none() {
        let json =
            r#"{"since":"2026-01-01T00:00:00Z","until":"2026-01-02T00:00:00Z","sessions":[]}"#;
        let parsed: SessionUsagePage = serde_json::from_str(json).unwrap();
        assert_eq!(parsed.next_cursor, None);
        assert!(parsed.sessions.is_empty());
    }

    #[test]
    fn usage_report_present_bundle_builds_round_trips() {
        let json = serde_json::to_string(&report(Some(BundleBuildUsage { requested: 3 }))).unwrap();
        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
        assert_eq!(
            parsed.bundle_builds,
            Some(BundleBuildUsage { requested: 3 })
        );
    }
}