simulator-client 0.7.1

Async WebSocket client for the Solana simulator backtest API
Documentation
use std::{error::Error, str::FromStr};

use chrono::{DateTime, NaiveDate, TimeZone, Utc};
use simulator_api::AvailableRange;

/// A slot number or a UTC timestamp, used to filter available ranges.
#[derive(Debug, Clone)]
pub enum RangeBound {
    Slot(u64),
    Time(DateTime<Utc>),
}

#[derive(Debug)]
pub struct ParseRangeBoundError(String);

impl std::fmt::Display for ParseRangeBoundError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl Error for ParseRangeBoundError {}

impl FromStr for RangeBound {
    type Err = ParseRangeBoundError;

    /// Parse from a string: tries a raw slot number first, then RFC 3339,
    /// then date-only "YYYY-MM-DD" (interpreted as midnight UTC).
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if let Ok(slot) = s.parse::<u64>() {
            return Ok(Self::Slot(slot));
        }
        if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
            return Ok(Self::Time(dt.with_timezone(&Utc)));
        }
        if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
            let dt = Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap());
            return Ok(Self::Time(dt));
        }
        Err(ParseRangeBoundError(format!(
            "could not parse {s:?} as a slot number, RFC 3339 timestamp, or YYYY-MM-DD date"
        )))
    }
}

/// Filter `ranges` to those bookended by `[after, before]`:
/// - `after`: keep only ranges whose start is at or after this bound
/// - `before`: keep only ranges whose end is at or before this bound
///
/// When a bound is a [`RangeBound::Time`], the range's UTC timestamp fields are used;
/// ranges that have no UTC timestamp for the relevant boundary are kept (conservative).
/// When a bound is a [`RangeBound::Slot`], slot fields are used directly.
pub fn filter_ranges(
    ranges: &[AvailableRange],
    after: Option<&RangeBound>,
    before: Option<&RangeBound>,
) -> Vec<AvailableRange> {
    let mut out = ranges.to_vec();

    if let Some(bound) = after {
        out.retain(|r| starts_after(r, bound));
    }
    if let Some(bound) = before {
        out.retain(|r| ends_before(r, bound));
    }

    out
}

fn ends_before(r: &AvailableRange, bound: &RangeBound) -> bool {
    match bound {
        RangeBound::Slot(slot) => r.max_bundle_end_slot.is_none_or(|end| end <= *slot),
        RangeBound::Time(time) => match &r.max_bundle_end_slot_utc {
            None => true, // unbounded or unknown — keep it
            Some(utc_str) => parse_utc(utc_str).is_none_or(|end| end <= *time),
        },
    }
}

fn starts_after(r: &AvailableRange, bound: &RangeBound) -> bool {
    match bound {
        RangeBound::Slot(slot) => r.bundle_start_slot >= *slot,
        RangeBound::Time(time) => match &r.bundle_start_slot_utc {
            None => true, // unknown — keep it
            Some(utc_str) => parse_utc(utc_str).is_none_or(|start| start >= *time),
        },
    }
}

fn parse_utc(s: &str) -> Option<DateTime<Utc>> {
    DateTime::parse_from_rfc3339(s)
        .ok()
        .map(|dt| dt.with_timezone(&Utc))
}