Skip to main content

simulator_api/
lib.rs

1pub mod subscribe_config;
2pub mod usage;
3
4use std::{
5    collections::{BTreeMap, BTreeSet, HashSet},
6    fmt,
7};
8
9use base64::{
10    DecodeError as Base64DecodeError, Engine as _, engine::general_purpose::STANDARD as BASE64,
11};
12use serde::{Deserialize, Serialize};
13use solana_address::Address;
14
15pub mod ws_compression;
16
17/// Backtest RPC methods exposed to the client.
18#[derive(Debug, Serialize, Deserialize)]
19#[serde(tag = "method", content = "params", rename_all = "camelCase")]
20pub enum BacktestRequest {
21    CreateBacktestSession(CreateBacktestSessionRequest),
22    Continue(ContinueParams),
23    ContinueTo(ContinueToParams),
24    ContinueSessionV1(ContinueSessionRequestV1),
25    ContinueToSessionV1(ContinueToSessionRequestV1),
26    CloseBacktestSession,
27    CloseSessionV1(CloseSessionRequestV1),
28    AttachBacktestSession {
29        session_id: String,
30        /// Last sequence number the client received. Responses after this sequence
31        /// will be replayed from the session's buffer. None = replay entire buffer.
32        last_sequence: Option<u64>,
33    },
34    /// Sent after reattaching and rebuilding any dependent subscriptions.
35    /// Allows the manager to resume a session that was paused for handoff.
36    ResumeAttachedSession,
37    AttachParallelControlSessionV2 {
38        control_session_id: String,
39        /// Last per-session sequence number received by the client. Responses after
40        /// these sequence numbers will be replayed from the manager's per-session
41        /// replay store. Missing sessions replay their entire retained history.
42        #[serde(default)]
43        last_sequences: BTreeMap<String, u64>,
44    },
45}
46
47/// Versioned payload for `CreateBacktestSession`.
48///
49/// - `V0` keeps backwards-compatible shape by using `CreateSessionParams` directly.
50/// - `V1` keeps the same shape and adds `parallel`.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(untagged)]
53pub enum CreateBacktestSessionRequest {
54    V1(CreateBacktestSessionRequestV1),
55    V0(CreateSessionParams),
56}
57
58impl CreateBacktestSessionRequest {
59    pub fn into_request_options(self) -> CreateBacktestSessionRequestOptions {
60        match self {
61            Self::V0(request) => CreateBacktestSessionRequestOptions {
62                request,
63                parallel: false,
64            },
65            Self::V1(CreateBacktestSessionRequestV1 { request, parallel }) => {
66                CreateBacktestSessionRequestOptions { request, parallel }
67            }
68        }
69    }
70
71    pub fn into_request_and_parallel(self) -> (CreateSessionParams, bool) {
72        let options = self.into_request_options();
73        (options.request, options.parallel)
74    }
75}
76
77impl From<CreateSessionParams> for CreateBacktestSessionRequest {
78    fn from(value: CreateSessionParams) -> Self {
79        Self::V0(value)
80    }
81}
82
83impl From<CreateBacktestSessionRequestV1> for CreateBacktestSessionRequest {
84    fn from(value: CreateBacktestSessionRequestV1) -> Self {
85        Self::V1(value)
86    }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct CreateBacktestSessionRequestV1 {
92    #[serde(flatten)]
93    pub request: CreateSessionParams,
94    pub parallel: bool,
95}
96
97#[derive(Debug, Clone)]
98pub struct CreateBacktestSessionRequestOptions {
99    pub request: CreateSessionParams,
100    pub parallel: bool,
101}
102
103#[derive(Debug, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct ContinueSessionRequestV1 {
106    pub session_id: String,
107    pub request: ContinueParams,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct ContinueToSessionRequestV1 {
113    pub session_id: String,
114    pub request: ContinueToParams,
115}
116
117#[derive(Debug, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct CloseSessionRequestV1 {
120    pub session_id: String,
121}
122
123/// A filter registered at session creation describing which upcoming batches
124/// the session should announce ahead of execution via
125/// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins). Each
126/// filter describes an event of interest (e.g. a specific program executing);
127/// the session "discovers" the batch in which that event will occur and
128/// emits a `DiscoveryBatch` so the client can pause immediately before it.
129#[serde_with::serde_as]
130#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
131#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
132pub enum DiscoveryFilter {
133    /// Discover batches containing a transaction that invokes this program.
134    ProgramExecuted(#[serde_as(as = "serde_with::DisplayFromStr")] Address),
135}
136
137/// Per-transaction facts a [`DiscoveryFilter`] can inspect when deciding
138/// whether to match. Callers build this once per transaction and feed it to
139/// every registered filter; new variants add fields here rather than growing
140/// the [`DiscoveryFilter::matches`] signature.
141pub struct TxMatchContext<'a> {
142    /// Programs invoked by the transaction (top-level + CPI observed in logs).
143    pub invoked_programs: &'a HashSet<Address>,
144}
145
146impl DiscoveryFilter {
147    /// Return `true` when this filter is satisfied by the transaction
148    /// described by `ctx`.
149    pub fn matches(&self, ctx: &TxMatchContext<'_>) -> bool {
150        match self {
151            Self::ProgramExecuted(target) => ctx.invoked_programs.contains(target),
152        }
153    }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub enum ActionKind {
159    Simulate,
160    Send,
161}
162
163/// Where a [`ScheduledAction`] fires.
164#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
165#[serde(tag = "at", rename_all = "camelCase")]
166pub enum ActionAnchor {
167    /// At the end of each slot, after its transactions execute but before it
168    /// finalizes.
169    #[default]
170    AfterSlot,
171    /// Before each batch matching `filter` executes.
172    BeforeMatch { filter: DiscoveryFilter },
173    /// After each batch matching `filter` commits.
174    AfterMatch { filter: DiscoveryFilter },
175}
176
177/// A transaction the server runs automatically during a backtest, with results
178/// streamed over the `actionSubscribe` subscription.
179#[serde_with::serde_as]
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct ScheduledAction {
183    #[serde(default)]
184    pub anchor: ActionAnchor,
185    pub kind: ActionKind,
186    /// Base64-encoded transaction.
187    pub transaction: String,
188    /// Accounts whose post-execution state to return, following
189    /// `simulateTransaction` semantics.
190    #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
191    #[serde(default)]
192    pub return_accounts: Vec<Address>,
193    /// Optional client tag echoed back on each `actionSubscribe` result to correlate it to this action.
194    #[serde(default)]
195    pub label: Option<String>,
196}
197
198/// Parameters required to start a new backtest session.
199#[serde_with::serde_as]
200#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
201#[serde(rename_all = "camelCase")]
202pub struct CreateSessionParams {
203    /// First slot (inclusive) to replay.
204    pub start_slot: u64,
205    /// Last slot (inclusive) to replay.
206    pub end_slot: u64,
207    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
208    #[serde(default)]
209    #[builder(default)]
210    /// Skip transactions signed by these addresses.
211    pub signer_filter: BTreeSet<Address>,
212    /// When true, include a session summary with transaction statistics in client-facing
213    /// `Completed` responses. Summary generation remains enabled internally for metrics.
214    #[serde(default)]
215    #[builder(default)]
216    pub send_summary: bool,
217    /// Maximum seconds to wait for ECS capacity-related startup retries before
218    /// failing session creation. If not set (or 0), capacity errors fail immediately.
219    #[serde(default)]
220    pub capacity_wait_timeout_secs: Option<u16>,
221    /// Maximum seconds to keep the session alive after the control websocket disconnects.
222    /// If not set (or 0), the session tears down immediately on disconnect.
223    /// Maximum value: 900 (15 minutes).
224    #[serde(default)]
225    pub disconnect_timeout_secs: Option<u16>,
226    /// Extra compute units to add to each transaction's `SetComputeUnitLimit` budget.
227    /// Useful when replaying with an account override whose program uses more CU than
228    /// the original, causing otherwise-healthy transactions to run out of budget.
229    /// Only applied when a `SetComputeUnitLimit` instruction is already present.
230    #[serde(default)]
231    pub extra_compute_units: Option<u32>,
232    /// Agent configurations to run as sidecars alongside this session.
233    #[serde(default)]
234    #[builder(default)]
235    pub agents: Vec<AgentParams>,
236    /// Events of interest the session should watch for. When an upcoming
237    /// batch matches any filter, the server emits
238    /// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins)
239    /// ahead of execution so the client can follow up with
240    /// [`BacktestRequest::ContinueTo`] to pause before the batch. Empty
241    /// means no batch discoveries are performed (existing behaviour).
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    #[builder(default)]
244    pub discoveries: Vec<DiscoveryFilter>,
245    /// Transactions the server runs automatically during the backtest, with
246    /// results streamed over `actionSubscribe`.
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    #[builder(default)]
249    pub actions: Vec<ScheduledAction>,
250}
251
252/// What counts as a "non-benign" divergence for the backtest session fail-fast
253/// behaviour (see `BacktestSessionOptions::fail_fast`).
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
255#[serde(rename_all = "kebab-case")]
256pub enum FailFastDivergenceKind {
257    /// Any divergence other than a benign log diff (the full `is_divergent()` set:
258    /// log mismatch, error mismatch, balance-diff mismatch).
259    #[default]
260    AnyNonBenign,
261    /// Only divergences on transactions that touch an account currently watched by an
262    /// account-diff subscription (subscribed directly, or owned by a subscribed program).
263    Tracked,
264}
265
266impl FailFastDivergenceKind {
267    /// Stable string form used for CLI/env-var serialization. Matches the serde
268    /// `kebab-case` representation.
269    pub fn as_str(self) -> &'static str {
270        match self {
271            Self::AnyNonBenign => "any-non-benign",
272            Self::Tracked => "tracked",
273        }
274    }
275
276    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
277    pub fn from_str_opt(value: &str) -> Option<Self> {
278        match value {
279            "any-non-benign" => Some(Self::AnyNonBenign),
280            "tracked" => Some(Self::Tracked),
281            _ => None,
282        }
283    }
284}
285
286/// Available agent types for sidecar participation in backtest sessions.
287#[derive(Debug, Clone, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub enum AgentType {
290    Arb,
291}
292
293/// Parameters for a circular arbitrage route.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase")]
296pub struct ArbRouteParams {
297    pub base_mint: String,
298    pub temp_mint: String,
299    #[serde(default)]
300    pub buy_dexes: Vec<String>,
301    #[serde(default)]
302    pub sell_dexes: Vec<String>,
303    pub min_input: u64,
304    pub max_input: u64,
305    #[serde(default)]
306    pub min_profit: u64,
307}
308
309/// Configuration for an agent to run alongside a backtest session.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase")]
312pub struct AgentParams {
313    pub agent_type: AgentType,
314    pub wallet: Option<String>,
315    /// Base58-encoded 64-byte keypair for signing transactions (compatible with `solana-keygen`).
316    pub keypair: Option<String>,
317    pub seed_sol_lamports: Option<u64>,
318    #[serde(default)]
319    pub seed_token_accounts: BTreeMap<String, u64>,
320    #[serde(default)]
321    pub arb_routes: Vec<ArbRouteParams>,
322}
323
324/// Account state modifications to apply.
325#[serde_with::serde_as]
326#[derive(Debug, Serialize, Deserialize, Default)]
327pub struct AccountModifications(
328    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
329    #[serde(default)]
330    pub BTreeMap<Address, AccountData>,
331);
332
333/// Arguments used to continue an existing session.
334#[serde_with::serde_as]
335#[derive(Debug, Serialize, Deserialize)]
336#[serde(rename_all = "camelCase")]
337pub struct ContinueParams {
338    #[serde(default = "ContinueParams::default_advance_count")]
339    /// Number of blocks to advance before waiting.
340    pub advance_count: u64,
341    #[serde(default)]
342    /// Base64-encoded transactions to execute before advancing.
343    pub transactions: Vec<String>,
344    #[serde(default)]
345    /// Account state overrides to apply ahead of execution.
346    pub modify_account_states: AccountModifications,
347}
348
349impl Default for ContinueParams {
350    fn default() -> Self {
351        Self {
352            advance_count: Self::default_advance_count(),
353            transactions: Vec::new(),
354            modify_account_states: AccountModifications(BTreeMap::new()),
355        }
356    }
357}
358
359impl ContinueParams {
360    pub fn default_advance_count() -> u64 {
361        1
362    }
363}
364
365/// Payload emitted when a session halts at a caller-specified point.
366/// `batch_index` is `None` for block-boundary pauses and `Some(n)` when the
367/// session stopped *before* batch `n` within `slot` (no transaction from
368/// batch `n` has been applied). While paused, RPC reads against the session
369/// observe partial state up through batch `n - 1`.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase")]
372pub struct PausedEvent {
373    pub slot: u64,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub batch_index: Option<u32>,
376}
377
378/// Payload emitted when the session has *discovered* an upcoming batch that
379/// matches one or more registered [`DiscoveryFilter`]s from session creation
380/// (for example, a batch containing a transaction that invokes a program of
381/// interest). The `(slot, batch_index)` pair can be fed directly to
382/// [`BacktestRequest::ContinueTo`] to pause immediately before the batch
383/// executes. After each `Continue` / `ContinueTo`, the session emits the
384/// next `DiscoveryBatchEvent` ahead of the next matching batch, enabling a
385/// reactive "pause on every discovery" driver loop.
386#[serde_with::serde_as]
387#[derive(Debug, Clone, Serialize, Deserialize)]
388#[serde(rename_all = "camelCase")]
389pub struct DiscoveryBatchEvent {
390    pub slot: u64,
391    pub batch_index: u32,
392    /// Filters that matched this batch (always non-empty).
393    pub matched: Vec<DiscoveryFilter>,
394    /// Encoded transactions in this batch that triggered the match. Each
395    /// entry carries the serialized `VersionedTransaction` bytes paired with
396    /// the encoding used.
397    pub transactions: Vec<EncodedBinary>,
398}
399
400/// Arguments used to step an existing session to a precise point.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402#[serde(rename_all = "camelCase")]
403pub struct ContinueToParams {
404    /// Target slot to stop in (or at, if `batch_index` is `None`).
405    pub slot: u64,
406    /// Batch within the target slot at which to pause, **exclusive** — the
407    /// session halts immediately *before* batch `n` executes, so no
408    /// transaction in that batch has been applied yet. `None` runs the
409    /// whole slot, pausing at the block boundary. While paused, RPC reads
410    /// observe partial state up through batch `n - 1`.
411    #[serde(default)]
412    pub batch_index: Option<u32>,
413}
414
415/// Supported binary encodings for account/transaction payloads.
416#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
417#[serde(rename_all = "lowercase")]
418pub enum BinaryEncoding {
419    Base64,
420}
421
422impl BinaryEncoding {
423    pub fn encode(self, bytes: &[u8]) -> String {
424        match self {
425            Self::Base64 => BASE64.encode(bytes),
426        }
427    }
428
429    pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
430        match self {
431            Self::Base64 => BASE64.decode(data),
432        }
433    }
434}
435
436/// A blob paired with the encoding needed to decode it.
437#[derive(Debug, Clone, Serialize, Deserialize)]
438#[serde(rename_all = "camelCase")]
439pub struct EncodedBinary {
440    /// Encoded payload.
441    pub data: String,
442    /// Encoding scheme used for the payload.
443    pub encoding: BinaryEncoding,
444}
445
446impl EncodedBinary {
447    pub fn new(data: String, encoding: BinaryEncoding) -> Self {
448        Self { data, encoding }
449    }
450
451    pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
452        Self {
453            data: encoding.encode(bytes),
454            encoding,
455        }
456    }
457
458    pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
459        self.encoding.decode(&self.data)
460    }
461}
462
463/// Account snapshot used to seed or modify state in a session.
464#[serde_with::serde_as]
465#[derive(Debug, Serialize, Deserialize)]
466#[serde(rename_all = "camelCase")]
467pub struct AccountData {
468    /// Account data bytes and encoding.
469    pub data: EncodedBinary,
470    /// Whether the account is executable.
471    pub executable: bool,
472    /// Lamport balance.
473    pub lamports: u64,
474    #[serde_as(as = "serde_with::DisplayFromStr")]
475    /// Account owner pubkey.
476    pub owner: Address,
477    /// Allocated space in bytes.
478    pub space: u64,
479}
480
481impl AccountData {
482    pub fn to_account(&self) -> Result<solana_account::Account, Base64DecodeError> {
483        Ok(solana_account::Account {
484            data: self.data.decode()?,
485            lamports: self.lamports,
486            owner: self.owner,
487            executable: self.executable,
488            rent_epoch: 0,
489        })
490    }
491}
492
493/// Responses returned over the backtest RPC channel.
494#[derive(Debug, Clone, Serialize, Deserialize)]
495#[serde(tag = "method", content = "params", rename_all = "camelCase")]
496pub enum BacktestResponse {
497    SessionCreated {
498        session_id: String,
499        rpc_endpoint: String,
500        #[serde(default, skip_serializing_if = "Option::is_none")]
501        task_id: Option<String>,
502    },
503    SessionAttached {
504        session_id: String,
505        rpc_endpoint: String,
506        #[serde(default, skip_serializing_if = "Option::is_none")]
507        task_id: Option<String>,
508    },
509    SessionsCreated {
510        session_ids: Vec<String>,
511    },
512    SessionsCreatedV2 {
513        control_session_id: String,
514        session_ids: Vec<String>,
515        #[serde(default)]
516        task_ids: Vec<Option<String>>,
517        /// Per-sub-session start/end slots, parallel to `session_ids`. The
518        /// server's split is authoritative (a mid-bundle request anchors to the
519        /// covering bundle), so the client binds each sub-session to its range
520        /// from here rather than re-deriving the split locally.
521        #[serde(default)]
522        start_slots: Vec<u64>,
523        #[serde(default)]
524        end_slots: Vec<u64>,
525    },
526    ParallelSessionAttachedV2 {
527        control_session_id: String,
528        session_ids: Vec<String>,
529        #[serde(default)]
530        task_ids: Vec<Option<String>>,
531    },
532    ReadyForContinue,
533    SlotNotification(u64),
534    Paused(PausedEvent),
535    DiscoveryBatch(DiscoveryBatchEvent),
536    Error(BacktestError),
537    Success,
538    Completed {
539        /// Session summary with transaction statistics.
540        /// The session always computes this summary, but management may omit it from
541        /// client-facing responses unless `send_summary` was requested at session creation.
542        #[serde(skip_serializing_if = "Option::is_none")]
543        summary: Option<SessionSummary>,
544        #[serde(default, skip_serializing_if = "Option::is_none")]
545        agent_stats: Option<Vec<AgentStatsReport>>,
546    },
547    Status {
548        status: BacktestStatus,
549    },
550    SessionEventV1 {
551        session_id: String,
552        event: SessionEventV1,
553    },
554    SessionEventV2 {
555        session_id: String,
556        seq_id: u64,
557        event: SessionEventKind,
558    },
559}
560
561impl BacktestResponse {
562    pub fn is_completed(&self) -> bool {
563        matches!(self, BacktestResponse::Completed { .. })
564    }
565
566    pub fn is_terminal(&self) -> bool {
567        match self {
568            BacktestResponse::Completed { .. } => true,
569            BacktestResponse::Error(e) => matches!(
570                e,
571                BacktestError::NoMoreBlocks
572                    | BacktestError::AdvanceSlotFailed { .. }
573                    | BacktestError::FinalizeSlotFailed { .. }
574                    | BacktestError::Internal { .. }
575            ),
576            _ => false,
577        }
578    }
579}
580
581impl From<BacktestStatus> for BacktestResponse {
582    fn from(status: BacktestStatus) -> Self {
583        Self::Status { status }
584    }
585}
586
587impl From<String> for BacktestResponse {
588    fn from(message: String) -> Self {
589        BacktestError::Internal { error: message }.into()
590    }
591}
592
593impl From<&str> for BacktestResponse {
594    fn from(message: &str) -> Self {
595        BacktestError::Internal {
596            error: message.to_string(),
597        }
598        .into()
599    }
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
603#[serde(tag = "method", content = "params", rename_all = "camelCase")]
604pub enum SessionEventV1 {
605    ReadyForContinue,
606    SlotNotification(u64),
607    Paused(PausedEvent),
608    DiscoveryBatch(DiscoveryBatchEvent),
609    Error(BacktestError),
610    Success,
611    Completed {
612        #[serde(skip_serializing_if = "Option::is_none")]
613        summary: Option<SessionSummary>,
614        #[serde(default, skip_serializing_if = "Option::is_none")]
615        agent_stats: Option<Vec<AgentStatsReport>>,
616    },
617    Status {
618        status: BacktestStatus,
619    },
620}
621
622#[derive(Debug, Clone, Serialize, Deserialize)]
623#[serde(tag = "method", content = "params", rename_all = "camelCase")]
624pub enum SessionEventKind {
625    ReadyForContinue,
626    SlotNotification(u64),
627    Paused(PausedEvent),
628    DiscoveryBatch(DiscoveryBatchEvent),
629    Error(BacktestError),
630    Success,
631    Completed {
632        #[serde(skip_serializing_if = "Option::is_none")]
633        summary: Option<SessionSummary>,
634    },
635    Status {
636        status: BacktestStatus,
637    },
638}
639
640impl SessionEventKind {
641    pub fn is_terminal(&self) -> bool {
642        match self {
643            Self::Completed { .. } => true,
644            Self::Error(e) => matches!(
645                e,
646                BacktestError::NoMoreBlocks
647                    | BacktestError::AdvanceSlotFailed { .. }
648                    | BacktestError::FinalizeSlotFailed { .. }
649                    | BacktestError::Internal { .. }
650            ),
651            _ => false,
652        }
653    }
654}
655
656/// Wire format wrapper for responses sent over the control websocket.
657/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
658#[derive(Debug, Clone, Serialize, Deserialize)]
659#[serde(rename_all = "camelCase")]
660pub struct SequencedResponse {
661    pub seq_id: u64,
662    #[serde(flatten)]
663    pub response: BacktestResponse,
664}
665
666/// High-level progress states during a `Continue` call.
667#[derive(Debug, Clone, Serialize, Deserialize)]
668#[serde(rename_all = "camelCase")]
669pub enum BacktestStatus {
670    /// Runtime startup is in progress.
671    StartingRuntime,
672    DecodedTransactions,
673    AppliedAccountModifications,
674    ReadyToExecuteUserTransactions,
675    ExecutedUserTransactions,
676    ExecutingBlockTransactions,
677    ExecutedBlockTransactions,
678    ProgramAccountsLoaded,
679}
680
681impl std::fmt::Display for BacktestStatus {
682    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
683        let s = match self {
684            Self::StartingRuntime => "starting runtime",
685            Self::DecodedTransactions => "decoded transactions",
686            Self::AppliedAccountModifications => "applied account modifications",
687            Self::ReadyToExecuteUserTransactions => "ready to execute user transactions",
688            Self::ExecutedUserTransactions => "executed user transactions",
689            Self::ExecutingBlockTransactions => "executing block transactions",
690            Self::ExecutedBlockTransactions => "executed block transactions",
691            Self::ProgramAccountsLoaded => "program accounts loaded",
692        };
693        f.write_str(s)
694    }
695}
696
697/// Structured stats reported by an agent during a backtest session.
698#[derive(Debug, Clone, Default, Serialize, Deserialize)]
699#[serde(rename_all = "camelCase")]
700pub struct AgentStatsReport {
701    pub name: String,
702    pub slots_processed: u64,
703    pub opportunities_found: u64,
704    pub opportunities_skipped: u64,
705    pub no_routes: u64,
706    pub txs_produced: u64,
707    /// Cumulative expected profit per base mint, keyed by mint address.
708    pub expected_gain_by_mint: BTreeMap<String, i64>,
709    /// Transactions successfully executed by the sidecar.
710    #[serde(default)]
711    pub txs_submitted: u64,
712    /// Transactions that failed execution.
713    #[serde(default)]
714    pub txs_failed: u64,
715    /// Transactions rejected by preflight simulation (unprofitable).
716    #[serde(default)]
717    pub txs_simulation_rejected: u64,
718    /// Preflight simulation RPC calls that errored.
719    #[serde(default)]
720    pub txs_simulation_failed: u64,
721}
722
723/// Summary of transaction execution statistics for a completed backtest session.
724#[derive(Debug, Clone, Default, Serialize, Deserialize)]
725#[serde(rename_all = "camelCase")]
726pub struct SessionSummary {
727    /// Number of simulations where simulator outcome matched on-chain outcome
728    /// (`true_success + true_failure`).
729    pub correct_simulation: usize,
730    /// Number of simulations where simulator outcome did not match on-chain outcome
731    /// (`false_success + false_failure`).
732    pub incorrect_simulation: usize,
733    /// Number of transactions that had execution errors in simulation.
734    pub execution_errors: usize,
735    /// Number of transactions with different balance diffs.
736    pub balance_diff: usize,
737    /// Number of transactions with different log diffs.
738    pub log_diff: usize,
739}
740
741impl SessionSummary {
742    /// Returns true if there were any execution deviations (errors or mismatched results).
743    pub fn has_deviations(&self) -> bool {
744        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
745    }
746
747    /// Total number of transactions processed.
748    pub fn total_transactions(&self) -> usize {
749        self.correct_simulation
750            + self.incorrect_simulation
751            + self.execution_errors
752            + self.balance_diff
753            + self.log_diff
754    }
755}
756
757impl std::fmt::Display for SessionSummary {
758    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
759        let total = self.total_transactions();
760        write!(
761            f,
762            "Session summary: {total} transactions\n\
763             \x20  - {} correct simulation\n\
764             \x20  - {} incorrect simulation\n\
765             \x20  - {} execution errors\n\
766             \x20  - {} balance diffs\n\
767             \x20  - {} log diffs",
768            self.correct_simulation,
769            self.incorrect_simulation,
770            self.execution_errors,
771            self.balance_diff,
772            self.log_diff,
773        )
774    }
775}
776
777/// Error variants surfaced to backtest RPC clients.
778#[derive(Debug, Clone, Serialize, Deserialize)]
779#[serde(rename_all = "camelCase")]
780pub enum BacktestError {
781    InvalidTransactionEncoding {
782        index: usize,
783        error: String,
784    },
785    InvalidTransactionFormat {
786        index: usize,
787        error: String,
788    },
789    InvalidAccountEncoding {
790        address: String,
791        encoding: BinaryEncoding,
792        error: String,
793    },
794    InvalidAccountOwner {
795        address: String,
796        error: String,
797    },
798    InvalidAccountPubkey {
799        address: String,
800        error: String,
801    },
802    NoMoreBlocks,
803    AdvanceSlotFailed {
804        slot: u64,
805        error: String,
806    },
807    FinalizeSlotFailed {
808        slot: u64,
809        error: String,
810    },
811    InvalidRequest {
812        error: String,
813    },
814    Internal {
815        error: String,
816    },
817    InvalidBlockhashFormat {
818        slot: u64,
819        error: String,
820    },
821    InitializingSysvarsFailed {
822        slot: u64,
823        error: String,
824    },
825    ClerkError {
826        error: String,
827    },
828    SimulationError {
829        error: String,
830    },
831    SessionNotFound {
832        session_id: String,
833    },
834    SessionOwnerMismatch,
835    /// Session ownership is in transition (e.g. the previous manager is
836    /// shutting down, or another attach raced this one). Clients should retry
837    /// the attach within their reconnect budget; the route is expected to
838    /// become claimable shortly.
839    SessionOwnershipBusy {
840        reason: String,
841    },
842}
843
844/// One contiguous block range available on the history clerk.
845#[derive(Debug, Clone, Serialize, Deserialize)]
846pub struct AvailableRange {
847    pub bundle_start_slot: u64,
848    pub bundle_start_slot_utc: Option<String>,
849    pub max_bundle_end_slot: Option<u64>,
850    pub max_bundle_end_slot_utc: Option<String>,
851    pub max_bundle_size: Option<u64>,
852}
853
854/// Split a user-requested `[start_slot, end_slot]` range across the available
855/// bundle ranges, returning a list of contiguous, non-overlapping `(start, end)`
856/// pairs — one per bundle that the server can serve as its own session.
857///
858/// Each emitted `start` is a real `bundle_start_slot` and each `end` is within
859/// the `max_bundle_end_slot` of a bundle that begins *exactly* at that start
860/// (the server requires `start_slot == bundle_start_slot` and caps `end_slot` at
861/// that bundle's `max_bundle_end_slot`). Among all such gap-free splits we pick
862/// the one with the most sub-ranges, i.e. the highest parallelism: a fine-grained
863/// (e.g. 10k) bundle grid yields one session per fine bundle instead of
864/// collapsing onto a coarser series that overlaps the same slots — even when the
865/// coarse and fine bundles share a start slot. A coarser bundle is only ridden
866/// where no finer grid continues the walk, which still lets us serve a request
867/// the coarse data covers even across a hole in the fine grid.
868///
869/// Returns an error if the requested start slot is not a bundle start, or if no
870/// gap-free split reaches `requested_end`.
871pub fn split_range(
872    ranges: &[AvailableRange],
873    requested_start: u64,
874    requested_end: u64,
875) -> Result<Vec<(u64, u64)>, String> {
876    if requested_end < requested_start {
877        return Err(format!(
878            "invalid range: start_slot {requested_start} > end_slot {requested_end}"
879        ));
880    }
881
882    // Every advertised end per bundle start. A start can carry several ends — a
883    // 50k snapshot bundle and the 10k bundle built on the same snapshot share a
884    // start slot but reach different ends — so we keep them all and let the walk
885    // pick whichever maximises parallelism.
886    let mut ends_by_start: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
887    for r in ranges {
888        if let Some(end) = r.max_bundle_end_slot
889            && end > r.bundle_start_slot
890        {
891            ends_by_start
892                .entry(r.bundle_start_slot)
893                .or_default()
894                .insert(end);
895        }
896    }
897
898    // Anchor a request that lands mid-bundle to the covering bundle's start —
899    // the latest bundle beginning at or before `requested_start` that still
900    // covers it. The server's `select_parallel_requests` shares this fn and
901    // anchors the same way, so the client must too or it would falsely reject a
902    // start that isn't itself a bundle boundary. An exact bundle-start request
903    // anchors to itself.
904    let Some((&anchor_start, _)) = ends_by_start.range(..=requested_start).rfind(|(_, ends)| {
905        ends.iter()
906            .next_back()
907            .is_some_and(|&end| end >= requested_start)
908    }) else {
909        return Err(format!(
910            "start_slot {requested_start} is not covered by any available bundle range"
911        ));
912    };
913
914    // Walk the bundle starts from `requested_end` backwards, recording for each
915    // start the gap-free split with the most sub-ranges (highest parallelism).
916    // A sub-range ends within one of its start's bundles and the next sub-range
917    // must begin on the very next slot, so a coarse bundle is ridden only when no
918    // finer bundle continues the walk.
919    let mut best_from: BTreeMap<u64, Vec<(u64, u64)>> = BTreeMap::new();
920    for (&start, ends) in ends_by_start.range(anchor_start..=requested_end).rev() {
921        let mut best: Option<Vec<(u64, u64)>> = None;
922        for &end in ends {
923            let candidate = if end >= requested_end {
924                Some(vec![(start, requested_end)])
925            } else {
926                best_from.get(&(end + 1)).map(|rest| {
927                    std::iter::once((start, end))
928                        .chain(rest.iter().copied())
929                        .collect()
930                })
931            };
932            if let Some(candidate) = candidate
933                && best.as_ref().is_none_or(|b| candidate.len() > b.len())
934            {
935                best = Some(candidate);
936            }
937        }
938        if let Some(best) = best {
939            best_from.insert(start, best);
940        }
941    }
942
943    best_from.remove(&anchor_start).ok_or_else(|| {
944        // Point at the first uncovered slot when the bundles leave a hole; fall
945        // back to a generic message when the range is covered but cannot be
946        // tiled to bundle boundaries.
947        let mut covered_to = anchor_start.saturating_sub(1);
948        for (&start, ends) in ends_by_start.range(anchor_start..=requested_end) {
949            if start > covered_to.saturating_add(1) {
950                break;
951            }
952            if let Some(&end) = ends.iter().next_back() {
953                covered_to = covered_to.max(end);
954            }
955        }
956        if covered_to < requested_end {
957            format!("gap in coverage at slot {}", covered_to + 1)
958        } else {
959            format!(
960                "no gap-free split of [{requested_start}, {requested_end}] aligns with the available bundle ranges"
961            )
962        }
963    })
964}
965
966impl From<BacktestError> for BacktestResponse {
967    fn from(error: BacktestError) -> Self {
968        Self::Error(error)
969    }
970}
971
972impl std::error::Error for BacktestError {}
973
974impl fmt::Display for BacktestError {
975    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
976        match self {
977            BacktestError::InvalidTransactionEncoding { index, error } => {
978                write!(f, "invalid transaction encoding at index {index}: {error}")
979            }
980            BacktestError::InvalidTransactionFormat { index, error } => {
981                write!(f, "invalid transaction format at index {index}: {error}")
982            }
983            BacktestError::InvalidAccountEncoding {
984                address,
985                encoding,
986                error,
987            } => write!(
988                f,
989                "invalid encoding for account {address} ({encoding:?}): {error}"
990            ),
991            BacktestError::InvalidAccountOwner { address, error } => {
992                write!(f, "invalid owner for account {address}: {error}")
993            }
994            BacktestError::InvalidAccountPubkey { address, error } => {
995                write!(f, "invalid account pubkey {address}: {error}")
996            }
997            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
998            BacktestError::AdvanceSlotFailed { slot, error } => {
999                write!(f, "failed to advance to slot {slot}: {error}")
1000            }
1001            BacktestError::FinalizeSlotFailed { slot, error } => {
1002                write!(f, "failed to finalize slot {slot}: {error}")
1003            }
1004            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
1005            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
1006            BacktestError::InvalidBlockhashFormat { slot, error } => {
1007                write!(f, "invalid blockhash at slot {slot}: {error}")
1008            }
1009            BacktestError::InitializingSysvarsFailed { slot, error } => {
1010                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
1011            }
1012            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
1013            BacktestError::SimulationError { error } => {
1014                write!(f, "simulation error: {error}")
1015            }
1016            BacktestError::SessionNotFound { session_id } => {
1017                write!(f, "session not found: {session_id}")
1018            }
1019            BacktestError::SessionOwnerMismatch => {
1020                write!(f, "session owner mismatch")
1021            }
1022            BacktestError::SessionOwnershipBusy { reason } => {
1023                write!(f, "session ownership busy: {reason}")
1024            }
1025        }
1026    }
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031    use super::*;
1032
1033    #[test]
1034    fn fail_fast_divergence_kind_str_round_trips() {
1035        for kind in [
1036            FailFastDivergenceKind::AnyNonBenign,
1037            FailFastDivergenceKind::Tracked,
1038        ] {
1039            assert_eq!(
1040                FailFastDivergenceKind::from_str_opt(kind.as_str()),
1041                Some(kind)
1042            );
1043        }
1044        assert_eq!(FailFastDivergenceKind::from_str_opt("nonsense"), None);
1045        assert_eq!(
1046            FailFastDivergenceKind::default(),
1047            FailFastDivergenceKind::AnyNonBenign
1048        );
1049    }
1050
1051    fn range(start: u64, end: u64) -> AvailableRange {
1052        AvailableRange {
1053            bundle_start_slot: start,
1054            bundle_start_slot_utc: None,
1055            max_bundle_end_slot: Some(end),
1056            max_bundle_end_slot_utc: None,
1057            max_bundle_size: None,
1058        }
1059    }
1060
1061    /// Each case lists the available bundles, the requested `[start, end]`, and
1062    /// the expected split — `Some(_)` for an accepted plan, `None` when the range
1063    /// is unservable and `split_range` must error.
1064    #[rstest::rstest]
1065    #[case::single(vec![range(100, 300)], 100, 300, Some(vec![(100, 300)]))]
1066    #[case::multi(
1067        vec![range(100, 200), range(201, 300), range(301, 400)],
1068        100, 300, Some(vec![(100, 200), (201, 300)])
1069    )]
1070    // Smaller bundles nested inside a larger one don't bridge the gap, but the
1071    // range is still coverable by riding the coarse series.
1072    #[case::nested(
1073        vec![range(100, 500), range(110, 150), range(150, 190), range(501, 900)],
1074        100, 900, Some(vec![(100, 500), (501, 900)])
1075    )]
1076    // A fine grid (1k bundles standing in for the 10k grid) overlapped by a
1077    // coarser, differently-aligned series: ride the fine grid, never landing on
1078    // the coarse bundle's drifted start.
1079    #[case::prefers_finer_grid(
1080        vec![range(1_000, 1_999), range(1_500, 3_400), range(2_000, 2_999), range(3_000, 3_999)],
1081        1_000, 3_999, Some(vec![(1_000, 1_999), (2_000, 2_999), (3_000, 3_999)])
1082    )]
1083    // A 50k snapshot bundle and the 10k bundles built on it share start 100: ride
1084    // the 10k grid instead of the coarse end, which would strand the cursor on a
1085    // slot no bundle starts at.
1086    #[case::shared_start_prefers_finer(
1087        vec![range(100, 150), range(100, 120), range(121, 140), range(141, 160)],
1088        100, 160, Some(vec![(100, 120), (121, 140), (141, 160)])
1089    )]
1090    // The fine grid has a hole (nothing starts at 141), but a coarse bundle
1091    // sharing the start spans it: serve the request off the coarse bundle rather
1092    // than reporting an unsupported range.
1093    #[case::falls_back_to_coarse(
1094        vec![range(100, 160), range(100, 120), range(121, 140)],
1095        100, 160, Some(vec![(100, 160)])
1096    )]
1097    // The last bundle overshoots the requested end and is clamped.
1098    #[case::clamps_final_bundle(vec![range(100, 199), range(200, 999)], 100, 450, Some(vec![(100, 199), (200, 450)]))]
1099    // A request landing mid-bundle anchors to the covering bundle's start
1100    // (matching the server) instead of erroring as "not covered".
1101    #[case::anchors_mid_bundle(vec![range(150, 350)], 200, 300, Some(vec![(150, 300)]))]
1102    #[case::anchors_then_continues(
1103        vec![range(150, 350), range(351, 600)],
1104        200, 600, Some(vec![(150, 350), (351, 600)])
1105    )]
1106    #[case::start_inside_bundle_anchors(vec![range(200, 400)], 300, 400, Some(vec![(200, 400)]))]
1107    // Errors: a start before any bundle's coverage has no covering bundle...
1108    #[case::start_before_first_bundle(vec![range(200, 400)], 100, 400, None)]
1109    // ...the end must be reachable...
1110    #[case::end_not_covered(vec![range(100, 200)], 100, 300, None)]
1111    #[case::gap_in_coverage(vec![range(100, 200), range(210, 300)], 100, 300, None)]
1112    // ...and the range must not be inverted.
1113    #[case::inverted_range(vec![range(100, 300)], 300, 100, None)]
1114    fn split_range_cases(
1115        #[case] ranges: Vec<AvailableRange>,
1116        #[case] start: u64,
1117        #[case] end: u64,
1118        #[case] expected: Option<Vec<(u64, u64)>>,
1119    ) {
1120        match expected {
1121            Some(expected) => assert_eq!(split_range(&ranges, start, end).unwrap(), expected),
1122            None => assert!(split_range(&ranges, start, end).is_err()),
1123        }
1124    }
1125
1126    /// Servable end per bundle start, as `split_range` sees it — every advertised
1127    /// end with `end > start`, keyed by start.
1128    fn ends_by_start(ranges: &[AvailableRange]) -> BTreeMap<u64, BTreeSet<u64>> {
1129        let mut ends: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
1130        for r in ranges {
1131            if let Some(end) = r.max_bundle_end_slot
1132                && end > r.bundle_start_slot
1133            {
1134                ends.entry(r.bundle_start_slot).or_default().insert(end);
1135            }
1136        }
1137        ends
1138    }
1139
1140    /// Independent spec for the optimum: the gap-free split of `[start, end]` with
1141    /// the most sub-ranges, found by exhaustively trying every advertised end at
1142    /// each cursor. `None` when no split aligns with the bundle starts.
1143    fn reference_max_split(
1144        ends: &BTreeMap<u64, BTreeSet<u64>>,
1145        cursor: u64,
1146        end: u64,
1147    ) -> Option<Vec<(u64, u64)>> {
1148        ends.get(&cursor)?
1149            .iter()
1150            .filter_map(|&bundle_end| {
1151                if bundle_end >= end {
1152                    Some(vec![(cursor, end)])
1153                } else {
1154                    reference_max_split(ends, bundle_end + 1, end).map(|mut rest| {
1155                        rest.insert(0, (cursor, bundle_end));
1156                        rest
1157                    })
1158                }
1159            })
1160            .max_by_key(Vec::len)
1161    }
1162
1163    /// Structure-independent check that a split is one the server can serve: it
1164    /// tiles `[start, end]` gap-free, every start is a real bundle start, and
1165    /// every end is within that start's largest advertised end.
1166    fn is_valid_split(
1167        split: &[(u64, u64)],
1168        ends: &BTreeMap<u64, BTreeSet<u64>>,
1169        start: u64,
1170        end: u64,
1171    ) -> bool {
1172        split.first().is_some_and(|&(s, _)| s == start)
1173            && split.last().is_some_and(|&(_, e)| e == end)
1174            && split.windows(2).all(|w| w[1].0 == w[0].1 + 1)
1175            && split.iter().all(|&(s, e)| {
1176                e >= s
1177                    && ends
1178                        .get(&s)
1179                        .and_then(|bundle_ends| bundle_ends.iter().next_back())
1180                        .is_some_and(|&max_end| e <= max_end)
1181            })
1182    }
1183
1184    /// Property test: across many randomized bundle layouts, `split_range` must
1185    /// return a *valid* split with the *maximum* number of sub-ranges whenever one
1186    /// exists, and error exactly when none does. Deterministic (fixed LCG seed) so
1187    /// a failure is reproducible.
1188    #[test]
1189    fn split_range_matches_reference() {
1190        let mut seed: u64 = 0x9E3779B97F4A7C15;
1191        let mut next = || {
1192            seed = seed
1193                .wrapping_mul(6364136223846793005)
1194                .wrapping_add(1442695040888963407);
1195            seed >> 33
1196        };
1197
1198        for _ in 0..50_000 {
1199            // A small slot universe makes shared starts, overlaps, nesting, and
1200            // gaps all common.
1201            let ranges: Vec<AvailableRange> = (0..next() % 6)
1202                .map(|_| {
1203                    let start = next() % 12;
1204                    range(start, start + next() % 6) // len 0 => filtered out
1205                })
1206                .collect();
1207            let start = next() % 12;
1208            let end = start + next() % 6; // sometimes start == end, sometimes unservable
1209
1210            let got = split_range(&ranges, start, end);
1211            let ends = ends_by_start(&ranges);
1212            // split_range anchors a mid-bundle start to the covering bundle's
1213            // start, so the reference must walk from the same anchor.
1214            let anchor = ends
1215                .range(..=start)
1216                .rfind(|(_, e)| e.iter().next_back().is_some_and(|&x| x >= start))
1217                .map(|(&s, _)| s);
1218            let reference = anchor.and_then(|a| reference_max_split(&ends, a, end));
1219
1220            let layout: Vec<_> = ranges
1221                .iter()
1222                .map(|r| (r.bundle_start_slot, r.max_bundle_end_slot))
1223                .collect();
1224            match (&got, &reference) {
1225                (Ok(split), Some(best)) => {
1226                    assert!(
1227                        is_valid_split(split, &ends, anchor.unwrap(), end),
1228                        "invalid split {split:?} for {layout:?} [{start},{end}]"
1229                    );
1230                    assert_eq!(
1231                        split.len(),
1232                        best.len(),
1233                        "suboptimal split {split:?} vs {best:?} for {layout:?} [{start},{end}]"
1234                    );
1235                }
1236                (Err(_), None) => {}
1237                _ => panic!(
1238                    "disagreement: split_range={got:?}, reference={reference:?} for {layout:?} [{start},{end}]"
1239                ),
1240            }
1241        }
1242    }
1243}