use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::SessionId;
#[derive(Debug, Serialize, Deserialize)]
pub struct UsageReport {
pub api_key_name: String,
pub since: DateTime<Utc>,
pub until: DateTime<Utc>,
sessions: SessionCounts,
compute: ComputeTotals,
#[serde(default)]
pub daily: Vec<UsageBucket>,
#[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 {
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,
}
}
pub fn sessions(&self) -> SessionCounts {
self.sessions
}
pub fn compute(&self) -> ComputeTotals {
self.compute
}
}
pub const BUCKET_SPAN: Duration = Duration::days(1);
#[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 {
pub fn is_partial(&self) -> bool {
self.end.signed_duration_since(self.start) < BUCKET_SPAN
}
pub fn is_empty(&self) -> bool {
self.sessions == SessionCounts::default() && self.compute == ComputeTotals::default()
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct SessionUsagePage {
pub since: DateTime<Utc>,
pub until: DateTime<Utc>,
pub sessions: Vec<SessionUsage>,
#[serde(default)]
pub next_cursor: Option<String>,
}
#[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,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
pub struct UsagePricing {
pub dollars_per_slot: f64,
pub dollars_per_minute: f64,
pub max_ms_per_slot: u64,
}
impl UsagePricing {
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
}
}
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
pub struct BundleBuildUsage {
pub requested: u64,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
pub struct SessionCounts {
pub started: u64,
pub completed: u64,
pub failed: u64,
}
#[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());
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);
}
#[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,
}
}
#[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 })
);
}
}