use std::time::Duration;
use clap::Parser;
use eyre::Result;
use sim_cli::ApiKeyArg;
use simulator_api::{
AvailableRange, BundleBuildRequest, BundleBuildStatus, BundleBuildStatusResponse,
};
use simulator_client::BacktestClient;
#[derive(Parser, Debug)]
pub struct BuildBundleArgs {
#[command(flatten)]
pub api_key: ApiKeyArg,
pub start_slot: Option<u64>,
pub end_slot: Option<u64>,
#[arg(long)]
pub latest: bool,
#[arg(long)]
pub bundle_size: Option<u64>,
#[arg(long)]
pub idempotency_key: Option<String>,
#[arg(long)]
pub wait: bool,
#[arg(long, default_value_t = 15, value_parser = clap::value_parser!(u64).range(1..))]
pub poll_secs: u64,
#[arg(long)]
pub json: bool,
}
pub async fn build_bundle(args: BuildBundleArgs, url: String) -> Result<()> {
let client = BacktestClient::builder()
.url(url)
.api_key(args.api_key.get())
.build();
let (start_slot, end_slot) = if args.latest
&& let Some(size) = args.bundle_size
{
let start = client.get_latest_snapshot_slot().await?.snapshot_slot + 1;
let end = start + size;
(start, end)
} else if let Some(start) = args.start_slot
&& let Some(end) = args.end_slot
{
(start, end)
} else {
eyre::bail!(
"specify a slot range: pass both START_SLOT and END_SLOT, or --latest with --bundle-size"
);
};
println!("Requesting bundle build for: {start_slot}-{end_slot}...");
let mut resp = client
.build_bundle(&BundleBuildRequest {
start_slot,
end_slot,
bundle_size: args.bundle_size,
idempotency_key: args.idempotency_key,
})
.await?;
let ranges = if args.json {
Vec::new()
} else {
client.available_ranges().await.unwrap_or_default()
};
if !args.json {
println!("{}", format_status("issued", &resp, &ranges));
}
if args.wait {
while matches!(resp.status, BundleBuildStatus::InProgress) {
tokio::time::sleep(Duration::from_secs(args.poll_secs)).await;
resp = client.get_bundle_status(&resp.request_id).await?;
if !args.json {
println!("{}", format_status("status", &resp, &ranges));
}
}
}
if args.json {
println!("{}", serde_json::to_string_pretty(&resp)?);
}
Ok(())
}
#[derive(Parser, Debug)]
pub struct BuildStatusArgs {
pub request_id: String,
#[command(flatten)]
pub api_key: ApiKeyArg,
#[arg(long)]
pub json: bool,
}
pub async fn get_status(args: BuildStatusArgs, url: String) -> Result<()> {
let client = BacktestClient::builder()
.url(url)
.api_key(args.api_key.get())
.build();
let resp = client.get_bundle_status(&args.request_id).await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&resp)?);
} else {
let ranges = client.available_ranges().await.unwrap_or_default();
println!("{}", format_status("status", &resp, &ranges));
}
Ok(())
}
fn format_status(
label: &str,
resp: &BundleBuildStatusResponse,
ranges: &[AvailableRange],
) -> String {
let size = resp
.bundle_size
.map_or_else(|| "default".to_string(), |s| s.to_string());
let eta = match resp.status {
BundleBuildStatus::InProgress => match estimate_remaining_secs(resp, ranges, now_unix_ms())
{
0 => " eta finishing up".to_string(),
secs => format!(" eta {}", format_duration(secs)),
},
_ => String::new(),
};
format!(
"{label}: {} [{}] slots {}..={} size={size}{eta}",
resp.request_id,
resp.status.as_str(),
resp.start_slot,
resp.end_slot,
)
}
const ORCHESTRATOR_SPINUP_SECS: u64 = 180;
const SNAPSHOT_DISCOVERY_SECS: u64 = 60;
const SNAPSHOT_IMPORT_SECS: u64 = 1500; const BLOCK_IMPORT_SPINUP_SECS: u64 = 180;
const BLOCK_RATE_HISTORIC: f64 = 130.0; const BLOCK_RATE_RECENT: f64 = 45.0; const BUNDLE_BUILD_SPINUP_SECS: u64 = 180;
const INTERIM_BUNDLE_SECS: u64 = 480; const FINAL_BUNDLE_SECS: u64 = 480; const SIMULATION_SECS_PER_SLOT: f64 = 0.2;
const SLOTS_PER_PARTITION: u64 = 1000;
const RECENT_WINDOW_SLOTS: u64 = 2 * 432_000;
fn estimate_remaining_secs(
resp: &BundleBuildStatusResponse,
ranges: &[AvailableRange],
now_ms: u64,
) -> u64 {
let total = estimate_total_secs(resp.start_slot, resp.end_slot, ranges);
let elapsed = now_ms.saturating_sub(resp.created_at_unix_ms) / 1000;
total.saturating_sub(elapsed)
}
fn estimate_total_secs(start: u64, end: u64, ranges: &[AvailableRange]) -> u64 {
let snapshot_cached = ranges.iter().any(|r| range_covers(r, start));
let blocks_cached = ranges
.iter()
.any(|r| range_covers(r, start) && range_covers(r, end));
let snapshot_secs = if snapshot_cached {
0
} else {
SNAPSHOT_IMPORT_SECS
};
let block_secs = if blocks_cached {
0
} else {
let rate = if is_recent(end, ranges) {
BLOCK_RATE_RECENT
} else {
BLOCK_RATE_HISTORIC
};
BLOCK_IMPORT_SPINUP_SECS + (partition_span(start, end) as f64 / rate) as u64
};
let phase_two = snapshot_secs.max(block_secs);
let gap = replay_gap(start, ranges);
let (interim, simulation) = if gap > 0 {
(
INTERIM_BUNDLE_SECS,
(gap as f64 * SIMULATION_SECS_PER_SLOT) as u64,
)
} else {
(0, 0)
};
ORCHESTRATOR_SPINUP_SECS
+ SNAPSHOT_DISCOVERY_SECS
+ phase_two
+ BUNDLE_BUILD_SPINUP_SECS
+ interim
+ simulation
+ FINAL_BUNDLE_SECS
}
fn range_covers(r: &AvailableRange, slot: u64) -> bool {
r.bundle_start_slot <= slot && r.max_bundle_end_slot.is_some_and(|end| slot <= end)
}
fn partition_span(start: u64, end: u64) -> u64 {
let lo = (start / SLOTS_PER_PARTITION) * SLOTS_PER_PARTITION;
let hi = (end / SLOTS_PER_PARTITION) * SLOTS_PER_PARTITION + (SLOTS_PER_PARTITION - 1);
hi.saturating_sub(lo) + 1
}
fn is_recent(end: u64, ranges: &[AvailableRange]) -> bool {
let latest = ranges
.iter()
.filter_map(|r| r.max_bundle_end_slot)
.max()
.unwrap_or(end);
latest.saturating_sub(end) <= RECENT_WINDOW_SLOTS
}
fn replay_gap(start: u64, ranges: &[AvailableRange]) -> u64 {
if ranges.iter().any(|r| range_covers(r, start)) {
return 0;
}
let anchor = ranges
.iter()
.filter_map(|r| r.max_bundle_end_slot)
.filter(|&end| end <= start)
.max()
.or_else(|| {
ranges
.iter()
.map(|r| r.bundle_start_slot)
.filter(|&s| s <= start)
.max()
})
.unwrap_or(start);
start.saturating_sub(anchor)
}
fn now_unix_ms() -> u64 {
chrono::Utc::now().timestamp_millis().max(0) as u64
}
fn format_duration(secs: u64) -> String {
let (hours, minutes) = (secs / 3600, (secs % 3600) / 60);
if hours > 0 {
format!("~{hours}h{minutes:02}m")
} else {
format!("~{minutes}m")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_positional_range_and_flags() {
let args = BuildBundleArgs::try_parse_from([
"build-bundle",
"100",
"200",
"--api-key",
"test-key",
"--bundle-size",
"500",
"--wait",
])
.unwrap();
assert_eq!((args.start_slot, args.end_slot), (Some(100), Some(200)));
assert_eq!(args.bundle_size, Some(500));
assert!(args.wait);
}
#[test]
fn poll_secs_rejects_zero() {
assert!(
BuildBundleArgs::try_parse_from([
"build-bundle",
"1",
"2",
"--api-key",
"k",
"--wait",
"--poll-secs",
"0",
])
.is_err()
);
}
fn range(start: u64, end: u64) -> AvailableRange {
AvailableRange {
bundle_start_slot: start,
bundle_start_slot_utc: None,
max_bundle_end_slot: Some(end),
max_bundle_end_slot_utc: None,
max_bundle_size: Some(end - start + 1),
}
}
fn in_progress(start: u64, end: u64, created_at_unix_ms: u64) -> BundleBuildStatusResponse {
BundleBuildStatusResponse {
request_id: "req-1".to_string(),
start_slot: start,
end_slot: end,
bundle_size: None,
status: BundleBuildStatus::InProgress,
created_at_unix_ms,
}
}
#[test]
fn formats_status_with_default_size() {
let resp = in_progress(1, 2, now_unix_ms());
let line = format_status("issued", &resp, &[]);
assert!(line.contains("req-1"));
assert!(line.contains("in_progress"));
assert!(line.contains("size=default"));
assert!(line.contains(" eta "));
}
#[test]
fn cached_range_is_cheaper_than_cold() {
let covered = [range(0, 2_000_000)];
let warm = estimate_total_secs(1_000_000, 1_100_000, &covered);
let cold = estimate_total_secs(1_000_000, 1_100_000, &[]);
assert!(warm < cold, "warm {warm} should be < cold {cold}");
}
#[test]
fn block_import_dominates_for_big_cold_range() {
let (start, end) = (1_000_000, 3_000_000); let block_path = BLOCK_IMPORT_SPINUP_SECS
+ (partition_span(start, end) as f64 / BLOCK_RATE_RECENT) as u64;
assert!(block_path > SNAPSHOT_IMPORT_SECS);
assert!(estimate_total_secs(start, end, &[]) >= block_path);
}
#[test]
fn countdown_decreases_and_floors_at_zero() {
let t0 = 1_000_000_000_000;
let resp = in_progress(1_000_000, 1_100_000, t0);
let r0 = estimate_remaining_secs(&resp, &[], t0);
let r1 = estimate_remaining_secs(&resp, &[], t0 + 60_000);
assert!(r1 < r0 && r1 + 60 == r0);
assert_eq!(estimate_remaining_secs(&resp, &[], t0 + 10_000_000_000), 0);
}
#[test]
fn recent_is_pricier_than_historic() {
let latest = [range(20_000_000, 21_000_000)];
let recent = estimate_total_secs(21_200_000, 21_300_000, &latest); let historic = estimate_total_secs(1_000_000, 1_100_000, &latest); assert!(recent > historic);
}
}