Skip to main content

simulator_api/
lib.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fmt,
4};
5
6use base64::{
7    DecodeError as Base64DecodeError, Engine as _, engine::general_purpose::STANDARD as BASE64,
8};
9use serde::{Deserialize, Serialize};
10use solana_address::Address;
11
12/// Backtest RPC methods exposed to the client.
13#[derive(Debug, Serialize, Deserialize)]
14#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
15#[serde(tag = "method", content = "params", rename_all = "camelCase")]
16#[cfg_attr(feature = "ts-rs", ts(export))]
17pub enum BacktestRequest {
18    CreateBacktestSession(CreateBacktestSessionRequest),
19    Continue(ContinueParams),
20    ContinueSessionV1(ContinueSessionRequestV1),
21    CloseBacktestSession,
22    CloseSessionV1(CloseSessionRequestV1),
23    AttachBacktestSession {
24        session_id: String,
25        /// Last sequence number the client received. Responses after this sequence
26        /// will be replayed from the session's buffer. None = replay entire buffer.
27        last_sequence: Option<u64>,
28    },
29}
30
31/// Versioned payload for `CreateBacktestSession`.
32///
33/// - `V0` keeps backwards-compatible shape by using `CreateSessionParams` directly.
34/// - `V1` keeps the same shape and adds `parallel`.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
37#[serde(untagged)]
38#[cfg_attr(feature = "ts-rs", ts(export))]
39pub enum CreateBacktestSessionRequest {
40    V1(CreateBacktestSessionRequestV1),
41    V0(CreateSessionParams),
42}
43
44impl CreateBacktestSessionRequest {
45    pub fn into_request_options(self) -> CreateBacktestSessionRequestOptions {
46        match self {
47            Self::V0(request) => CreateBacktestSessionRequestOptions {
48                request,
49                parallel: false,
50            },
51            Self::V1(CreateBacktestSessionRequestV1 { request, parallel }) => {
52                CreateBacktestSessionRequestOptions { request, parallel }
53            }
54        }
55    }
56
57    pub fn into_request_and_parallel(self) -> (CreateSessionParams, bool) {
58        let options = self.into_request_options();
59        (options.request, options.parallel)
60    }
61}
62
63impl From<CreateSessionParams> for CreateBacktestSessionRequest {
64    fn from(value: CreateSessionParams) -> Self {
65        Self::V0(value)
66    }
67}
68
69impl From<CreateBacktestSessionRequestV1> for CreateBacktestSessionRequest {
70    fn from(value: CreateBacktestSessionRequestV1) -> Self {
71        Self::V1(value)
72    }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
77#[serde(rename_all = "camelCase")]
78#[cfg_attr(feature = "ts-rs", ts(export))]
79pub struct CreateBacktestSessionRequestV1 {
80    #[serde(flatten)]
81    pub request: CreateSessionParams,
82    pub parallel: bool,
83}
84
85#[derive(Debug, Clone)]
86pub struct CreateBacktestSessionRequestOptions {
87    pub request: CreateSessionParams,
88    pub parallel: bool,
89}
90
91#[derive(Debug, Serialize, Deserialize)]
92#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
93#[serde(rename_all = "camelCase")]
94#[cfg_attr(feature = "ts-rs", ts(export))]
95pub struct ContinueSessionRequestV1 {
96    pub session_id: String,
97    pub request: ContinueParams,
98}
99
100#[derive(Debug, Serialize, Deserialize)]
101#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
102#[serde(rename_all = "camelCase")]
103#[cfg_attr(feature = "ts-rs", ts(export))]
104pub struct CloseSessionRequestV1 {
105    pub session_id: String,
106}
107
108/// Parameters required to start a new backtest session.
109#[serde_with::serde_as]
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
112#[serde(rename_all = "camelCase")]
113pub struct CreateSessionParams {
114    /// First slot (inclusive) to replay.
115    pub start_slot: u64,
116    /// Last slot (inclusive) to replay.
117    pub end_slot: u64,
118    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
119    #[serde(default)]
120    #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
121    /// Skip transactions signed by these addresses.
122    pub signer_filter: BTreeSet<Address>,
123    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
124    #[serde(default)]
125    #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
126    /// Programs to preload before executing.
127    pub preload_programs: BTreeSet<Address>,
128    /// Account bundle IDs to preload before executing.
129    #[serde(default)]
130    pub preload_account_bundles: Vec<String>,
131    /// When true, include a session summary with transaction statistics in client-facing
132    /// `Completed` responses. Summary generation remains enabled internally for metrics.
133    #[serde(default)]
134    pub send_summary: bool,
135    /// Maximum seconds to wait for ECS capacity-related startup retries before
136    /// failing session creation. If not set (or 0), capacity errors fail immediately.
137    #[serde(default)]
138    #[cfg_attr(feature = "ts-rs", ts(optional))]
139    pub capacity_wait_timeout_secs: Option<u16>,
140    /// Maximum seconds to keep the session alive after the control websocket disconnects.
141    /// If not set (or 0), the session tears down immediately on disconnect.
142    /// Maximum value: 900 (15 minutes).
143    #[serde(default)]
144    pub disconnect_timeout_secs: Option<u16>,
145    /// Extra compute units to add to each transaction's `SetComputeUnitLimit` budget.
146    /// Useful when replaying with an account override whose program uses more CU than
147    /// the original, causing otherwise-healthy transactions to run out of budget.
148    /// Only applied when a `SetComputeUnitLimit` instruction is already present.
149    #[serde(default)]
150    pub extra_compute_units: Option<u32>,
151    /// Agent configurations to run as sidecars alongside this session.
152    #[serde(default)]
153    pub agents: Vec<AgentParams>,
154}
155
156/// Available agent types for sidecar participation in backtest sessions.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
159#[serde(rename_all = "camelCase")]
160#[cfg_attr(feature = "ts-rs", ts(export))]
161pub enum AgentType {
162    Arb,
163}
164
165/// Parameters for a circular arbitrage route.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
168#[serde(rename_all = "camelCase")]
169#[cfg_attr(feature = "ts-rs", ts(export))]
170pub struct ArbRouteParams {
171    pub base_mint: String,
172    pub temp_mint: String,
173    #[serde(default)]
174    pub buy_dexes: Vec<String>,
175    #[serde(default)]
176    pub sell_dexes: Vec<String>,
177    pub min_input: u64,
178    pub max_input: u64,
179    #[serde(default)]
180    pub min_profit: u64,
181}
182
183/// Configuration for an agent to run alongside a backtest session.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
186#[serde(rename_all = "camelCase")]
187#[cfg_attr(feature = "ts-rs", ts(export))]
188pub struct AgentParams {
189    pub agent_type: AgentType,
190    pub wallet: Option<String>,
191    pub seed_sol_lamports: Option<u64>,
192    #[serde(default)]
193    pub arb_routes: Vec<ArbRouteParams>,
194}
195
196/// Account state modifications to apply.
197#[serde_with::serde_as]
198#[derive(Debug, Serialize, Deserialize, Default)]
199#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
200pub struct AccountModifications(
201    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
202    #[serde(default)]
203    #[cfg_attr(feature = "ts-rs", ts(as = "BTreeMap<String, AccountData>"))]
204    pub BTreeMap<Address, AccountData>,
205);
206
207/// Arguments used to continue an existing session.
208#[serde_with::serde_as]
209#[derive(Debug, Serialize, Deserialize)]
210#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
211#[serde(rename_all = "camelCase")]
212pub struct ContinueParams {
213    #[serde(default = "ContinueParams::default_advance_count")]
214    /// Number of blocks to advance before waiting.
215    pub advance_count: u64,
216    #[serde(default)]
217    /// Base64-encoded transactions to execute before advancing.
218    pub transactions: Vec<String>,
219    #[serde(default)]
220    /// Account state overrides to apply ahead of execution.
221    pub modify_account_states: AccountModifications,
222}
223
224impl Default for ContinueParams {
225    fn default() -> Self {
226        Self {
227            advance_count: Self::default_advance_count(),
228            transactions: Vec::new(),
229            modify_account_states: AccountModifications(BTreeMap::new()),
230        }
231    }
232}
233
234impl ContinueParams {
235    pub fn default_advance_count() -> u64 {
236        1
237    }
238}
239
240/// Supported binary encodings for account/transaction payloads.
241#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
242#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
243#[serde(rename_all = "lowercase")]
244pub enum BinaryEncoding {
245    Base64,
246}
247
248impl BinaryEncoding {
249    pub fn encode(self, bytes: &[u8]) -> String {
250        match self {
251            Self::Base64 => BASE64.encode(bytes),
252        }
253    }
254
255    pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
256        match self {
257            Self::Base64 => BASE64.decode(data),
258        }
259    }
260}
261
262/// A blob paired with the encoding needed to decode it.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
265#[serde(rename_all = "camelCase")]
266pub struct EncodedBinary {
267    /// Encoded payload.
268    pub data: String,
269    /// Encoding scheme used for the payload.
270    pub encoding: BinaryEncoding,
271}
272
273impl EncodedBinary {
274    pub fn new(data: String, encoding: BinaryEncoding) -> Self {
275        Self { data, encoding }
276    }
277
278    pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
279        Self {
280            data: encoding.encode(bytes),
281            encoding,
282        }
283    }
284
285    pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
286        self.encoding.decode(&self.data)
287    }
288}
289
290/// Account snapshot used to seed or modify state in a session.
291#[serde_with::serde_as]
292#[derive(Debug, Serialize, Deserialize)]
293#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
294#[serde(rename_all = "camelCase")]
295pub struct AccountData {
296    /// Account data bytes and encoding.
297    pub data: EncodedBinary,
298    /// Whether the account is executable.
299    pub executable: bool,
300    /// Lamport balance.
301    pub lamports: u64,
302    #[serde_as(as = "serde_with::DisplayFromStr")]
303    #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
304    /// Account owner pubkey.
305    pub owner: Address,
306    /// Allocated space in bytes.
307    pub space: u64,
308}
309
310/// Responses returned over the backtest RPC channel.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
313#[serde(tag = "method", content = "params", rename_all = "camelCase")]
314#[cfg_attr(feature = "ts-rs", ts(export))]
315pub enum BacktestResponse {
316    SessionCreated {
317        session_id: String,
318        rpc_endpoint: String,
319    },
320    SessionAttached {
321        session_id: String,
322        rpc_endpoint: String,
323    },
324    SessionsCreated {
325        session_ids: Vec<String>,
326    },
327    ReadyForContinue,
328    SlotNotification(u64),
329    Error(BacktestError),
330    Success,
331    Completed {
332        /// Session summary with transaction statistics.
333        /// The session always computes this summary, but management may omit it from
334        /// client-facing responses unless `send_summary` was requested at session creation.
335        #[serde(skip_serializing_if = "Option::is_none")]
336        summary: Option<SessionSummary>,
337    },
338    Status {
339        status: BacktestStatus,
340    },
341    SessionEventV1 {
342        session_id: String,
343        event: SessionEventV1,
344    },
345}
346
347impl BacktestResponse {
348    pub fn is_completed(&self) -> bool {
349        matches!(self, BacktestResponse::Completed { .. })
350    }
351
352    pub fn is_terminal(&self) -> bool {
353        match self {
354            BacktestResponse::Completed { .. } => true,
355            BacktestResponse::Error(e) => matches!(
356                e,
357                BacktestError::NoMoreBlocks
358                    | BacktestError::AdvanceSlotFailed { .. }
359                    | BacktestError::Internal { .. }
360            ),
361            _ => false,
362        }
363    }
364}
365
366impl From<BacktestStatus> for BacktestResponse {
367    fn from(status: BacktestStatus) -> Self {
368        Self::Status { status }
369    }
370}
371
372impl From<String> for BacktestResponse {
373    fn from(message: String) -> Self {
374        BacktestError::Internal { error: message }.into()
375    }
376}
377
378impl From<&str> for BacktestResponse {
379    fn from(message: &str) -> Self {
380        BacktestError::Internal {
381            error: message.to_string(),
382        }
383        .into()
384    }
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
388#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
389#[serde(tag = "method", content = "params", rename_all = "camelCase")]
390#[cfg_attr(feature = "ts-rs", ts(export))]
391pub enum SessionEventV1 {
392    ReadyForContinue,
393    SlotNotification(u64),
394    Error(BacktestError),
395    Success,
396    Completed {
397        #[serde(skip_serializing_if = "Option::is_none")]
398        summary: Option<SessionSummary>,
399    },
400    Status {
401        status: BacktestStatus,
402    },
403}
404
405/// Wire format wrapper for responses sent over the control websocket.
406/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
407#[derive(Debug, Clone, Serialize, Deserialize)]
408#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
409#[serde(rename_all = "camelCase")]
410#[cfg_attr(feature = "ts-rs", ts(export))]
411pub struct SequencedResponse {
412    pub seq_id: u64,
413    #[serde(flatten)]
414    pub response: BacktestResponse,
415}
416
417/// High-level progress states during a `Continue` call.
418#[derive(Debug, Clone, Serialize, Deserialize)]
419#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
420#[serde(rename_all = "camelCase")]
421pub enum BacktestStatus {
422    /// Runtime startup is in progress.
423    StartingRuntime,
424    DecodedTransactions,
425    AppliedAccountModifications,
426    ReadyToExecuteUserTransactions,
427    ExecutedUserTransactions,
428    ExecutingBlockTransactions,
429    ExecutedBlockTransactions,
430    ProgramAccountsLoaded,
431}
432
433/// Summary of transaction execution statistics for a completed backtest session.
434#[derive(Debug, Clone, Default, Serialize, Deserialize)]
435#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
436#[serde(rename_all = "camelCase")]
437pub struct SessionSummary {
438    /// Number of simulations where simulator outcome matched on-chain outcome
439    /// (`true_success + true_failure`).
440    pub correct_simulation: usize,
441    /// Number of simulations where simulator outcome did not match on-chain outcome
442    /// (`false_success + false_failure`).
443    pub incorrect_simulation: usize,
444    /// Number of transactions that had execution errors in simulation.
445    pub execution_errors: usize,
446    /// Number of transactions with different balance diffs.
447    pub balance_diff: usize,
448    /// Number of transactions with different log diffs.
449    pub log_diff: usize,
450}
451
452impl SessionSummary {
453    /// Returns true if there were any execution deviations (errors or mismatched results).
454    pub fn has_deviations(&self) -> bool {
455        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
456    }
457
458    /// Total number of transactions processed.
459    pub fn total_transactions(&self) -> usize {
460        self.correct_simulation
461            + self.incorrect_simulation
462            + self.execution_errors
463            + self.balance_diff
464            + self.log_diff
465    }
466}
467
468impl std::fmt::Display for SessionSummary {
469    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470        let total = self.total_transactions();
471        write!(
472            f,
473            "Session summary: {total} transactions\n\
474             \x20  - {} correct simulation\n\
475             \x20  - {} incorrect simulation\n\
476             \x20  - {} execution errors\n\
477             \x20  - {} balance diffs\n\
478             \x20  - {} log diffs",
479            self.correct_simulation,
480            self.incorrect_simulation,
481            self.execution_errors,
482            self.balance_diff,
483            self.log_diff,
484        )
485    }
486}
487
488/// Error variants surfaced to backtest RPC clients.
489#[derive(Debug, Clone, Serialize, Deserialize)]
490#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
491#[serde(rename_all = "camelCase")]
492pub enum BacktestError {
493    InvalidTransactionEncoding {
494        index: usize,
495        error: String,
496    },
497    InvalidTransactionFormat {
498        index: usize,
499        error: String,
500    },
501    InvalidAccountEncoding {
502        address: String,
503        encoding: BinaryEncoding,
504        error: String,
505    },
506    InvalidAccountOwner {
507        address: String,
508        error: String,
509    },
510    InvalidAccountPubkey {
511        address: String,
512        error: String,
513    },
514    NoMoreBlocks,
515    AdvanceSlotFailed {
516        slot: u64,
517        error: String,
518    },
519    InvalidRequest {
520        error: String,
521    },
522    Internal {
523        error: String,
524    },
525    InvalidBlockhashFormat {
526        slot: u64,
527        error: String,
528    },
529    InitializingSysvarsFailed {
530        slot: u64,
531        error: String,
532    },
533    ClerkError {
534        error: String,
535    },
536    SimulationError {
537        error: String,
538    },
539    SessionNotFound {
540        session_id: String,
541    },
542    SessionOwnerMismatch,
543}
544
545/// One contiguous block range available on the history clerk.
546#[derive(Debug, Clone, Serialize, Deserialize)]
547pub struct AvailableRange {
548    pub bundle_start_slot: u64,
549    pub bundle_start_slot_utc: Option<String>,
550    pub max_bundle_end_slot: Option<u64>,
551    pub max_bundle_end_slot_utc: Option<String>,
552}
553
554/// Split a user-requested `[start_slot, end_slot]` range across the available
555/// bundle ranges, returning a list of non-overlapping `(start, end)` pairs — one
556/// per bundle that intersects the requested range.
557///
558/// Returns an error if the requested start slot is not covered by any range.
559pub fn split_range(
560    ranges: &[AvailableRange],
561    requested_start: u64,
562    requested_end: u64,
563) -> Result<Vec<(u64, u64)>, String> {
564    if requested_end < requested_start {
565        return Err(format!(
566            "invalid range: start_slot {requested_start} > end_slot {requested_end}"
567        ));
568    }
569
570    let mut candidates: Vec<(u64, u64)> = ranges
571        .iter()
572        .filter_map(|r| Some((r.bundle_start_slot, r.max_bundle_end_slot?)))
573        .filter(|(start, end)| end >= start)
574        .collect();
575
576    candidates.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
577    candidates.dedup_by_key(|(start, _)| *start);
578
579    if candidates.is_empty() {
580        return Err("no available bundle ranges found on server".to_string());
581    }
582
583    let mut non_overlapping: Vec<(u64, u64)> = Vec::with_capacity(candidates.len());
584    for (i, (start, mut end)) in candidates.iter().copied().enumerate() {
585        if let Some((next_start, _)) = candidates.get(i + 1).copied()
586            && next_start <= end
587        {
588            end = next_start.saturating_sub(1);
589        }
590        if end >= start {
591            non_overlapping.push((start, end));
592        }
593    }
594
595    let anchor = non_overlapping
596        .iter()
597        .enumerate()
598        .rev()
599        .find(|(_, (s, e))| *s <= requested_start && *e >= requested_start)
600        .map(|(i, _)| i)
601        .ok_or_else(|| {
602            format!("start_slot {requested_start} is not covered by any available bundle range")
603        })?;
604
605    let mut result = Vec::new();
606    for (start, range_end) in non_overlapping.into_iter().skip(anchor) {
607        if start > requested_end {
608            break;
609        }
610        let end = range_end.min(requested_end);
611        if end >= start {
612            result.push((start, end));
613        }
614        if end == requested_end {
615            break;
616        }
617    }
618
619    if result.is_empty() {
620        return Err(format!(
621            "no available bundle ranges intersect requested range [{requested_start}-{requested_end}]"
622        ));
623    }
624
625    Ok(result)
626}
627
628impl From<BacktestError> for BacktestResponse {
629    fn from(error: BacktestError) -> Self {
630        Self::Error(error)
631    }
632}
633
634impl std::error::Error for BacktestError {}
635
636impl fmt::Display for BacktestError {
637    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
638        match self {
639            BacktestError::InvalidTransactionEncoding { index, error } => {
640                write!(f, "invalid transaction encoding at index {index}: {error}")
641            }
642            BacktestError::InvalidTransactionFormat { index, error } => {
643                write!(f, "invalid transaction format at index {index}: {error}")
644            }
645            BacktestError::InvalidAccountEncoding {
646                address,
647                encoding,
648                error,
649            } => write!(
650                f,
651                "invalid encoding for account {address} ({encoding:?}): {error}"
652            ),
653            BacktestError::InvalidAccountOwner { address, error } => {
654                write!(f, "invalid owner for account {address}: {error}")
655            }
656            BacktestError::InvalidAccountPubkey { address, error } => {
657                write!(f, "invalid account pubkey {address}: {error}")
658            }
659            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
660            BacktestError::AdvanceSlotFailed { slot, error } => {
661                write!(f, "failed to advance to slot {slot}: {error}")
662            }
663            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
664            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
665            BacktestError::InvalidBlockhashFormat { slot, error } => {
666                write!(f, "invalid blockhash at slot {slot}: {error}")
667            }
668            BacktestError::InitializingSysvarsFailed { slot, error } => {
669                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
670            }
671            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
672            BacktestError::SimulationError { error } => {
673                write!(f, "simulation error: {error}")
674            }
675            BacktestError::SessionNotFound { session_id } => {
676                write!(f, "session not found: {session_id}")
677            }
678            BacktestError::SessionOwnerMismatch => {
679                write!(f, "session owner mismatch")
680            }
681        }
682    }
683}