Skip to main content

simulator_api/
lib.rs

1pub mod usage;
2
3use std::{
4    collections::{BTreeMap, BTreeSet, HashSet},
5    fmt,
6};
7
8use base64::{
9    DecodeError as Base64DecodeError, Engine as _, engine::general_purpose::STANDARD as BASE64,
10};
11use serde::{Deserialize, Serialize};
12use solana_address::Address;
13
14/// Backtest RPC methods exposed to the client.
15#[derive(Debug, Serialize, Deserialize)]
16#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
17#[serde(tag = "method", content = "params", rename_all = "camelCase")]
18#[cfg_attr(feature = "ts-rs", ts(export))]
19pub enum BacktestRequest {
20    CreateBacktestSession(CreateBacktestSessionRequest),
21    Continue(ContinueParams),
22    ContinueTo(ContinueToParams),
23    ContinueSessionV1(ContinueSessionRequestV1),
24    ContinueToSessionV1(ContinueToSessionRequestV1),
25    CloseBacktestSession,
26    CloseSessionV1(CloseSessionRequestV1),
27    AttachBacktestSession {
28        session_id: String,
29        /// Last sequence number the client received. Responses after this sequence
30        /// will be replayed from the session's buffer. None = replay entire buffer.
31        last_sequence: Option<u64>,
32    },
33    /// Sent after reattaching and rebuilding any dependent subscriptions.
34    /// Allows the manager to resume a session that was paused for handoff.
35    ResumeAttachedSession,
36    AttachParallelControlSessionV2 {
37        control_session_id: String,
38        /// Last per-session sequence number received by the client. Responses after
39        /// these sequence numbers will be replayed from the manager's per-session
40        /// replay store. Missing sessions replay their entire retained history.
41        #[serde(default)]
42        last_sequences: BTreeMap<String, u64>,
43    },
44}
45
46/// Versioned payload for `CreateBacktestSession`.
47///
48/// - `V0` keeps backwards-compatible shape by using `CreateSessionParams` directly.
49/// - `V1` keeps the same shape and adds `parallel`.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
52#[serde(untagged)]
53#[cfg_attr(feature = "ts-rs", ts(export))]
54pub enum CreateBacktestSessionRequest {
55    V1(CreateBacktestSessionRequestV1),
56    V0(CreateSessionParams),
57}
58
59impl CreateBacktestSessionRequest {
60    pub fn into_request_options(self) -> CreateBacktestSessionRequestOptions {
61        match self {
62            Self::V0(request) => CreateBacktestSessionRequestOptions {
63                request,
64                parallel: false,
65            },
66            Self::V1(CreateBacktestSessionRequestV1 { request, parallel }) => {
67                CreateBacktestSessionRequestOptions { request, parallel }
68            }
69        }
70    }
71
72    pub fn into_request_and_parallel(self) -> (CreateSessionParams, bool) {
73        let options = self.into_request_options();
74        (options.request, options.parallel)
75    }
76}
77
78impl From<CreateSessionParams> for CreateBacktestSessionRequest {
79    fn from(value: CreateSessionParams) -> Self {
80        Self::V0(value)
81    }
82}
83
84impl From<CreateBacktestSessionRequestV1> for CreateBacktestSessionRequest {
85    fn from(value: CreateBacktestSessionRequestV1) -> Self {
86        Self::V1(value)
87    }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
92#[serde(rename_all = "camelCase")]
93#[cfg_attr(feature = "ts-rs", ts(export))]
94pub struct CreateBacktestSessionRequestV1 {
95    #[serde(flatten)]
96    pub request: CreateSessionParams,
97    pub parallel: bool,
98}
99
100#[derive(Debug, Clone)]
101pub struct CreateBacktestSessionRequestOptions {
102    pub request: CreateSessionParams,
103    pub parallel: bool,
104}
105
106#[derive(Debug, Serialize, Deserialize)]
107#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
108#[serde(rename_all = "camelCase")]
109#[cfg_attr(feature = "ts-rs", ts(export))]
110pub struct ContinueSessionRequestV1 {
111    pub session_id: String,
112    pub request: ContinueParams,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
117#[serde(rename_all = "camelCase")]
118#[cfg_attr(feature = "ts-rs", ts(export))]
119pub struct ContinueToSessionRequestV1 {
120    pub session_id: String,
121    pub request: ContinueToParams,
122}
123
124#[derive(Debug, Serialize, Deserialize)]
125#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
126#[serde(rename_all = "camelCase")]
127#[cfg_attr(feature = "ts-rs", ts(export))]
128pub struct CloseSessionRequestV1 {
129    pub session_id: String,
130}
131
132/// A filter registered at session creation describing which upcoming batches
133/// the session should announce ahead of execution via
134/// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins). Each
135/// filter describes an event of interest (e.g. a specific program executing);
136/// the session "discovers" the batch in which that event will occur and
137/// emits a `DiscoveryBatch` so the client can pause immediately before it.
138#[serde_with::serde_as]
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
141#[cfg_attr(feature = "ts-rs", ts(export))]
142#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
143pub enum DiscoveryFilter {
144    /// Discover batches containing a transaction that invokes this program.
145    ProgramExecuted(
146        #[serde_as(as = "serde_with::DisplayFromStr")]
147        #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
148        Address,
149    ),
150}
151
152/// Per-transaction facts a [`DiscoveryFilter`] can inspect when deciding
153/// whether to match. Callers build this once per transaction and feed it to
154/// every registered filter; new variants add fields here rather than growing
155/// the [`DiscoveryFilter::matches`] signature.
156pub struct TxMatchContext<'a> {
157    /// Programs invoked by the transaction (top-level + CPI observed in logs).
158    pub invoked_programs: &'a HashSet<Address>,
159}
160
161impl DiscoveryFilter {
162    /// Return `true` when this filter is satisfied by the transaction
163    /// described by `ctx`.
164    pub fn matches(&self, ctx: &TxMatchContext<'_>) -> bool {
165        match self {
166            Self::ProgramExecuted(target) => ctx.invoked_programs.contains(target),
167        }
168    }
169}
170
171/// Parameters required to start a new backtest session.
172#[serde_with::serde_as]
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
175#[serde(rename_all = "camelCase")]
176pub struct CreateSessionParams {
177    /// First slot (inclusive) to replay.
178    pub start_slot: u64,
179    /// Last slot (inclusive) to replay.
180    pub end_slot: u64,
181    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
182    #[serde(default)]
183    #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
184    /// Skip transactions signed by these addresses.
185    pub signer_filter: BTreeSet<Address>,
186    /// When true, include a session summary with transaction statistics in client-facing
187    /// `Completed` responses. Summary generation remains enabled internally for metrics.
188    #[serde(default)]
189    pub send_summary: bool,
190    /// Maximum seconds to wait for ECS capacity-related startup retries before
191    /// failing session creation. If not set (or 0), capacity errors fail immediately.
192    #[serde(default)]
193    #[cfg_attr(feature = "ts-rs", ts(optional))]
194    pub capacity_wait_timeout_secs: Option<u16>,
195    /// Maximum seconds to keep the session alive after the control websocket disconnects.
196    /// If not set (or 0), the session tears down immediately on disconnect.
197    /// Maximum value: 900 (15 minutes).
198    #[serde(default)]
199    pub disconnect_timeout_secs: Option<u16>,
200    /// Extra compute units to add to each transaction's `SetComputeUnitLimit` budget.
201    /// Useful when replaying with an account override whose program uses more CU than
202    /// the original, causing otherwise-healthy transactions to run out of budget.
203    /// Only applied when a `SetComputeUnitLimit` instruction is already present.
204    #[serde(default)]
205    pub extra_compute_units: Option<u32>,
206    /// Agent configurations to run as sidecars alongside this session.
207    #[serde(default)]
208    pub agents: Vec<AgentParams>,
209    /// Events of interest the session should watch for. When an upcoming
210    /// batch matches any filter, the server emits
211    /// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins)
212    /// ahead of execution so the client can follow up with
213    /// [`BacktestRequest::ContinueTo`] to pause before the batch. Empty
214    /// means no batch discoveries are performed (existing behaviour).
215    #[serde(default, skip_serializing_if = "Vec::is_empty")]
216    pub discoveries: Vec<DiscoveryFilter>,
217}
218
219/// Available agent types for sidecar participation in backtest sessions.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
222#[serde(rename_all = "camelCase")]
223#[cfg_attr(feature = "ts-rs", ts(export))]
224pub enum AgentType {
225    Arb,
226}
227
228/// Parameters for a circular arbitrage route.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
231#[serde(rename_all = "camelCase")]
232#[cfg_attr(feature = "ts-rs", ts(export))]
233pub struct ArbRouteParams {
234    pub base_mint: String,
235    pub temp_mint: String,
236    #[serde(default)]
237    pub buy_dexes: Vec<String>,
238    #[serde(default)]
239    pub sell_dexes: Vec<String>,
240    pub min_input: u64,
241    pub max_input: u64,
242    #[serde(default)]
243    pub min_profit: u64,
244}
245
246/// Configuration for an agent to run alongside a backtest session.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
249#[serde(rename_all = "camelCase")]
250#[cfg_attr(feature = "ts-rs", ts(export))]
251pub struct AgentParams {
252    pub agent_type: AgentType,
253    pub wallet: Option<String>,
254    /// Base58-encoded 64-byte keypair for signing transactions (compatible with `solana-keygen`).
255    pub keypair: Option<String>,
256    pub seed_sol_lamports: Option<u64>,
257    #[serde(default)]
258    pub seed_token_accounts: BTreeMap<String, u64>,
259    #[serde(default)]
260    pub arb_routes: Vec<ArbRouteParams>,
261}
262
263/// Account state modifications to apply.
264#[serde_with::serde_as]
265#[derive(Debug, Serialize, Deserialize, Default)]
266#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
267pub struct AccountModifications(
268    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
269    #[serde(default)]
270    #[cfg_attr(feature = "ts-rs", ts(as = "BTreeMap<String, AccountData>"))]
271    pub BTreeMap<Address, AccountData>,
272);
273
274/// Arguments used to continue an existing session.
275#[serde_with::serde_as]
276#[derive(Debug, Serialize, Deserialize)]
277#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
278#[serde(rename_all = "camelCase")]
279pub struct ContinueParams {
280    #[serde(default = "ContinueParams::default_advance_count")]
281    /// Number of blocks to advance before waiting.
282    pub advance_count: u64,
283    #[serde(default)]
284    /// Base64-encoded transactions to execute before advancing.
285    pub transactions: Vec<String>,
286    #[serde(default)]
287    /// Account state overrides to apply ahead of execution.
288    pub modify_account_states: AccountModifications,
289}
290
291impl Default for ContinueParams {
292    fn default() -> Self {
293        Self {
294            advance_count: Self::default_advance_count(),
295            transactions: Vec::new(),
296            modify_account_states: AccountModifications(BTreeMap::new()),
297        }
298    }
299}
300
301impl ContinueParams {
302    pub fn default_advance_count() -> u64 {
303        1
304    }
305}
306
307/// Payload emitted when a session halts at a caller-specified point.
308/// `batch_index` is `None` for block-boundary pauses and `Some(n)` when the
309/// session stopped *before* batch `n` within `slot` (no transaction from
310/// batch `n` has been applied). While paused, RPC reads against the session
311/// observe partial state up through batch `n - 1`.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
314#[cfg_attr(feature = "ts-rs", ts(export))]
315#[serde(rename_all = "camelCase")]
316pub struct PausedEvent {
317    pub slot: u64,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub batch_index: Option<u32>,
320}
321
322/// Payload emitted when the session has *discovered* an upcoming batch that
323/// matches one or more registered [`DiscoveryFilter`]s from session creation
324/// (for example, a batch containing a transaction that invokes a program of
325/// interest). The `(slot, batch_index)` pair can be fed directly to
326/// [`BacktestRequest::ContinueTo`] to pause immediately before the batch
327/// executes. After each `Continue` / `ContinueTo`, the session emits the
328/// next `DiscoveryBatchEvent` ahead of the next matching batch, enabling a
329/// reactive "pause on every discovery" driver loop.
330#[serde_with::serde_as]
331#[derive(Debug, Clone, Serialize, Deserialize)]
332#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
333#[cfg_attr(feature = "ts-rs", ts(export))]
334#[serde(rename_all = "camelCase")]
335pub struct DiscoveryBatchEvent {
336    pub slot: u64,
337    pub batch_index: u32,
338    /// Filters that matched this batch (always non-empty).
339    pub matched: Vec<DiscoveryFilter>,
340    /// Encoded transactions in this batch that triggered the match. Each
341    /// entry carries the serialized `VersionedTransaction` bytes paired with
342    /// the encoding used.
343    pub transactions: Vec<EncodedBinary>,
344}
345
346/// Arguments used to step an existing session to a precise point.
347#[derive(Debug, Clone, Serialize, Deserialize)]
348#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
349#[cfg_attr(feature = "ts-rs", ts(export))]
350#[serde(rename_all = "camelCase")]
351pub struct ContinueToParams {
352    /// Target slot to stop in (or at, if `batch_index` is `None`).
353    pub slot: u64,
354    /// Batch within the target slot at which to pause, **exclusive** — the
355    /// session halts immediately *before* batch `n` executes, so no
356    /// transaction in that batch has been applied yet. `None` runs the
357    /// whole slot, pausing at the block boundary. While paused, RPC reads
358    /// observe partial state up through batch `n - 1`.
359    #[serde(default)]
360    pub batch_index: Option<u32>,
361}
362
363/// Supported binary encodings for account/transaction payloads.
364#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
365#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
366#[serde(rename_all = "lowercase")]
367pub enum BinaryEncoding {
368    Base64,
369}
370
371impl BinaryEncoding {
372    pub fn encode(self, bytes: &[u8]) -> String {
373        match self {
374            Self::Base64 => BASE64.encode(bytes),
375        }
376    }
377
378    pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
379        match self {
380            Self::Base64 => BASE64.decode(data),
381        }
382    }
383}
384
385/// A blob paired with the encoding needed to decode it.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
388#[serde(rename_all = "camelCase")]
389pub struct EncodedBinary {
390    /// Encoded payload.
391    pub data: String,
392    /// Encoding scheme used for the payload.
393    pub encoding: BinaryEncoding,
394}
395
396impl EncodedBinary {
397    pub fn new(data: String, encoding: BinaryEncoding) -> Self {
398        Self { data, encoding }
399    }
400
401    pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
402        Self {
403            data: encoding.encode(bytes),
404            encoding,
405        }
406    }
407
408    pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
409        self.encoding.decode(&self.data)
410    }
411}
412
413/// Account snapshot used to seed or modify state in a session.
414#[serde_with::serde_as]
415#[derive(Debug, Serialize, Deserialize)]
416#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
417#[serde(rename_all = "camelCase")]
418pub struct AccountData {
419    /// Account data bytes and encoding.
420    pub data: EncodedBinary,
421    /// Whether the account is executable.
422    pub executable: bool,
423    /// Lamport balance.
424    pub lamports: u64,
425    #[serde_as(as = "serde_with::DisplayFromStr")]
426    #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
427    /// Account owner pubkey.
428    pub owner: Address,
429    /// Allocated space in bytes.
430    pub space: u64,
431}
432
433/// Responses returned over the backtest RPC channel.
434#[derive(Debug, Clone, Serialize, Deserialize)]
435#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
436#[serde(tag = "method", content = "params", rename_all = "camelCase")]
437#[cfg_attr(feature = "ts-rs", ts(export))]
438pub enum BacktestResponse {
439    SessionCreated {
440        session_id: String,
441        rpc_endpoint: String,
442        #[serde(default, skip_serializing_if = "Option::is_none")]
443        task_id: Option<String>,
444    },
445    SessionAttached {
446        session_id: String,
447        rpc_endpoint: String,
448        #[serde(default, skip_serializing_if = "Option::is_none")]
449        task_id: Option<String>,
450    },
451    SessionsCreated {
452        session_ids: Vec<String>,
453    },
454    SessionsCreatedV2 {
455        control_session_id: String,
456        session_ids: Vec<String>,
457        #[serde(default)]
458        task_ids: Vec<Option<String>>,
459    },
460    ParallelSessionAttachedV2 {
461        control_session_id: String,
462        session_ids: Vec<String>,
463        #[serde(default)]
464        task_ids: Vec<Option<String>>,
465    },
466    ReadyForContinue,
467    SlotNotification(u64),
468    Paused(PausedEvent),
469    DiscoveryBatch(DiscoveryBatchEvent),
470    Error(BacktestError),
471    Success,
472    Completed {
473        /// Session summary with transaction statistics.
474        /// The session always computes this summary, but management may omit it from
475        /// client-facing responses unless `send_summary` was requested at session creation.
476        #[serde(skip_serializing_if = "Option::is_none")]
477        summary: Option<SessionSummary>,
478        #[serde(default, skip_serializing_if = "Option::is_none")]
479        agent_stats: Option<Vec<AgentStatsReport>>,
480    },
481    Status {
482        status: BacktestStatus,
483    },
484    SessionEventV1 {
485        session_id: String,
486        event: SessionEventV1,
487    },
488    SessionEventV2 {
489        session_id: String,
490        seq_id: u64,
491        event: SessionEventKind,
492    },
493}
494
495impl BacktestResponse {
496    pub fn is_completed(&self) -> bool {
497        matches!(self, BacktestResponse::Completed { .. })
498    }
499
500    pub fn is_terminal(&self) -> bool {
501        match self {
502            BacktestResponse::Completed { .. } => true,
503            BacktestResponse::Error(e) => matches!(
504                e,
505                BacktestError::NoMoreBlocks
506                    | BacktestError::AdvanceSlotFailed { .. }
507                    | BacktestError::FinalizeSlotFailed { .. }
508                    | BacktestError::Internal { .. }
509            ),
510            _ => false,
511        }
512    }
513}
514
515impl From<BacktestStatus> for BacktestResponse {
516    fn from(status: BacktestStatus) -> Self {
517        Self::Status { status }
518    }
519}
520
521impl From<String> for BacktestResponse {
522    fn from(message: String) -> Self {
523        BacktestError::Internal { error: message }.into()
524    }
525}
526
527impl From<&str> for BacktestResponse {
528    fn from(message: &str) -> Self {
529        BacktestError::Internal {
530            error: message.to_string(),
531        }
532        .into()
533    }
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
537#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
538#[serde(tag = "method", content = "params", rename_all = "camelCase")]
539#[cfg_attr(feature = "ts-rs", ts(export))]
540pub enum SessionEventV1 {
541    ReadyForContinue,
542    SlotNotification(u64),
543    Paused(PausedEvent),
544    DiscoveryBatch(DiscoveryBatchEvent),
545    Error(BacktestError),
546    Success,
547    Completed {
548        #[serde(skip_serializing_if = "Option::is_none")]
549        summary: Option<SessionSummary>,
550        #[serde(default, skip_serializing_if = "Option::is_none")]
551        agent_stats: Option<Vec<AgentStatsReport>>,
552    },
553    Status {
554        status: BacktestStatus,
555    },
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
559#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
560#[serde(tag = "method", content = "params", rename_all = "camelCase")]
561#[cfg_attr(feature = "ts-rs", ts(export))]
562pub enum SessionEventKind {
563    ReadyForContinue,
564    SlotNotification(u64),
565    Paused(PausedEvent),
566    DiscoveryBatch(DiscoveryBatchEvent),
567    Error(BacktestError),
568    Success,
569    Completed {
570        #[serde(skip_serializing_if = "Option::is_none")]
571        summary: Option<SessionSummary>,
572    },
573    Status {
574        status: BacktestStatus,
575    },
576}
577
578impl SessionEventKind {
579    pub fn is_terminal(&self) -> bool {
580        match self {
581            Self::Completed { .. } => true,
582            Self::Error(e) => matches!(
583                e,
584                BacktestError::NoMoreBlocks
585                    | BacktestError::AdvanceSlotFailed { .. }
586                    | BacktestError::FinalizeSlotFailed { .. }
587                    | BacktestError::Internal { .. }
588            ),
589            _ => false,
590        }
591    }
592}
593
594/// Wire format wrapper for responses sent over the control websocket.
595/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
596#[derive(Debug, Clone, Serialize, Deserialize)]
597#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
598#[serde(rename_all = "camelCase")]
599#[cfg_attr(feature = "ts-rs", ts(export))]
600pub struct SequencedResponse {
601    pub seq_id: u64,
602    #[serde(flatten)]
603    pub response: BacktestResponse,
604}
605
606/// High-level progress states during a `Continue` call.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
609#[serde(rename_all = "camelCase")]
610pub enum BacktestStatus {
611    /// Runtime startup is in progress.
612    StartingRuntime,
613    DecodedTransactions,
614    AppliedAccountModifications,
615    ReadyToExecuteUserTransactions,
616    ExecutedUserTransactions,
617    ExecutingBlockTransactions,
618    ExecutedBlockTransactions,
619    ProgramAccountsLoaded,
620}
621
622impl std::fmt::Display for BacktestStatus {
623    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
624        let s = match self {
625            Self::StartingRuntime => "starting runtime",
626            Self::DecodedTransactions => "decoded transactions",
627            Self::AppliedAccountModifications => "applied account modifications",
628            Self::ReadyToExecuteUserTransactions => "ready to execute user transactions",
629            Self::ExecutedUserTransactions => "executed user transactions",
630            Self::ExecutingBlockTransactions => "executing block transactions",
631            Self::ExecutedBlockTransactions => "executed block transactions",
632            Self::ProgramAccountsLoaded => "program accounts loaded",
633        };
634        f.write_str(s)
635    }
636}
637
638/// Structured stats reported by an agent during a backtest session.
639#[derive(Debug, Clone, Default, Serialize, Deserialize)]
640#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
641#[serde(rename_all = "camelCase")]
642#[cfg_attr(feature = "ts-rs", ts(export))]
643pub struct AgentStatsReport {
644    pub name: String,
645    pub slots_processed: u64,
646    pub opportunities_found: u64,
647    pub opportunities_skipped: u64,
648    pub no_routes: u64,
649    pub txs_produced: u64,
650    /// Cumulative expected profit per base mint, keyed by mint address.
651    pub expected_gain_by_mint: BTreeMap<String, i64>,
652    /// Transactions successfully executed by the sidecar.
653    #[serde(default)]
654    pub txs_submitted: u64,
655    /// Transactions that failed execution.
656    #[serde(default)]
657    pub txs_failed: u64,
658    /// Transactions rejected by preflight simulation (unprofitable).
659    #[serde(default)]
660    pub txs_simulation_rejected: u64,
661    /// Preflight simulation RPC calls that errored.
662    #[serde(default)]
663    pub txs_simulation_failed: u64,
664}
665
666/// Summary of transaction execution statistics for a completed backtest session.
667#[derive(Debug, Clone, Default, Serialize, Deserialize)]
668#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
669#[serde(rename_all = "camelCase")]
670pub struct SessionSummary {
671    /// Number of simulations where simulator outcome matched on-chain outcome
672    /// (`true_success + true_failure`).
673    pub correct_simulation: usize,
674    /// Number of simulations where simulator outcome did not match on-chain outcome
675    /// (`false_success + false_failure`).
676    pub incorrect_simulation: usize,
677    /// Number of transactions that had execution errors in simulation.
678    pub execution_errors: usize,
679    /// Number of transactions with different balance diffs.
680    pub balance_diff: usize,
681    /// Number of transactions with different log diffs.
682    pub log_diff: usize,
683}
684
685impl SessionSummary {
686    /// Returns true if there were any execution deviations (errors or mismatched results).
687    pub fn has_deviations(&self) -> bool {
688        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
689    }
690
691    /// Total number of transactions processed.
692    pub fn total_transactions(&self) -> usize {
693        self.correct_simulation
694            + self.incorrect_simulation
695            + self.execution_errors
696            + self.balance_diff
697            + self.log_diff
698    }
699}
700
701impl std::fmt::Display for SessionSummary {
702    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703        let total = self.total_transactions();
704        write!(
705            f,
706            "Session summary: {total} transactions\n\
707             \x20  - {} correct simulation\n\
708             \x20  - {} incorrect simulation\n\
709             \x20  - {} execution errors\n\
710             \x20  - {} balance diffs\n\
711             \x20  - {} log diffs",
712            self.correct_simulation,
713            self.incorrect_simulation,
714            self.execution_errors,
715            self.balance_diff,
716            self.log_diff,
717        )
718    }
719}
720
721/// Error variants surfaced to backtest RPC clients.
722#[derive(Debug, Clone, Serialize, Deserialize)]
723#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
724#[serde(rename_all = "camelCase")]
725pub enum BacktestError {
726    InvalidTransactionEncoding {
727        index: usize,
728        error: String,
729    },
730    InvalidTransactionFormat {
731        index: usize,
732        error: String,
733    },
734    InvalidAccountEncoding {
735        address: String,
736        encoding: BinaryEncoding,
737        error: String,
738    },
739    InvalidAccountOwner {
740        address: String,
741        error: String,
742    },
743    InvalidAccountPubkey {
744        address: String,
745        error: String,
746    },
747    NoMoreBlocks,
748    AdvanceSlotFailed {
749        slot: u64,
750        error: String,
751    },
752    FinalizeSlotFailed {
753        slot: u64,
754        error: String,
755    },
756    InvalidRequest {
757        error: String,
758    },
759    Internal {
760        error: String,
761    },
762    InvalidBlockhashFormat {
763        slot: u64,
764        error: String,
765    },
766    InitializingSysvarsFailed {
767        slot: u64,
768        error: String,
769    },
770    ClerkError {
771        error: String,
772    },
773    SimulationError {
774        error: String,
775    },
776    SessionNotFound {
777        session_id: String,
778    },
779    SessionOwnerMismatch,
780    /// Session ownership is in transition (e.g. the previous manager is
781    /// shutting down, or another attach raced this one). Clients should retry
782    /// the attach within their reconnect budget; the route is expected to
783    /// become claimable shortly.
784    SessionOwnershipBusy {
785        reason: String,
786    },
787}
788
789/// One contiguous block range available on the history clerk.
790#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct AvailableRange {
792    pub bundle_start_slot: u64,
793    pub bundle_start_slot_utc: Option<String>,
794    pub max_bundle_end_slot: Option<u64>,
795    pub max_bundle_end_slot_utc: Option<String>,
796    pub max_bundle_size: Option<u64>,
797}
798
799/// Split a user-requested `[start_slot, end_slot]` range across the available
800/// bundle ranges, returning a list of non-overlapping `(start, end)` pairs — one
801/// per bundle that intersects the requested range.
802///
803/// Returns an error if the requested start slot is not covered by any range.
804pub fn split_range(
805    ranges: &[AvailableRange],
806    requested_start: u64,
807    requested_end: u64,
808) -> Result<Vec<(u64, u64)>, String> {
809    if requested_end < requested_start {
810        return Err(format!(
811            "invalid range: start_slot {requested_start} > end_slot {requested_end}"
812        ));
813    }
814
815    let mut candidates: Vec<(u64, u64)> = ranges
816        .iter()
817        .filter_map(|r| Some((r.bundle_start_slot, r.max_bundle_end_slot?)))
818        .filter(|(start, end)| {
819            end > start
820                && *start >= requested_start
821                && *start < requested_end
822                && *end > requested_start
823        })
824        .collect();
825
826    candidates.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
827    candidates.dedup_by_key(|(start, _)| *start);
828
829    if candidates.is_empty() || candidates.first().unwrap().0 != requested_start {
830        return Err(format!(
831            "start_slot {requested_start} is not covered by any available bundle range"
832        ));
833    }
834
835    let mut result: Vec<(u64, u64)> = Vec::new();
836    let mut current_slot = requested_start;
837    let mut i = 0;
838    let mut best_end = 0u64;
839
840    while current_slot <= requested_end {
841        while i < candidates.len() && candidates[i].0 <= current_slot {
842            best_end = best_end.max(candidates[i].1);
843            i += 1;
844        }
845        if best_end < current_slot {
846            return Err(format!("gap in coverage at slot {current_slot}"));
847        }
848        let range_end = best_end.min(requested_end);
849        result.push((current_slot, range_end));
850        current_slot = range_end + 1;
851    }
852
853    Ok(result)
854}
855
856impl From<BacktestError> for BacktestResponse {
857    fn from(error: BacktestError) -> Self {
858        Self::Error(error)
859    }
860}
861
862impl std::error::Error for BacktestError {}
863
864impl fmt::Display for BacktestError {
865    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
866        match self {
867            BacktestError::InvalidTransactionEncoding { index, error } => {
868                write!(f, "invalid transaction encoding at index {index}: {error}")
869            }
870            BacktestError::InvalidTransactionFormat { index, error } => {
871                write!(f, "invalid transaction format at index {index}: {error}")
872            }
873            BacktestError::InvalidAccountEncoding {
874                address,
875                encoding,
876                error,
877            } => write!(
878                f,
879                "invalid encoding for account {address} ({encoding:?}): {error}"
880            ),
881            BacktestError::InvalidAccountOwner { address, error } => {
882                write!(f, "invalid owner for account {address}: {error}")
883            }
884            BacktestError::InvalidAccountPubkey { address, error } => {
885                write!(f, "invalid account pubkey {address}: {error}")
886            }
887            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
888            BacktestError::AdvanceSlotFailed { slot, error } => {
889                write!(f, "failed to advance to slot {slot}: {error}")
890            }
891            BacktestError::FinalizeSlotFailed { slot, error } => {
892                write!(f, "failed to finalize slot {slot}: {error}")
893            }
894            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
895            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
896            BacktestError::InvalidBlockhashFormat { slot, error } => {
897                write!(f, "invalid blockhash at slot {slot}: {error}")
898            }
899            BacktestError::InitializingSysvarsFailed { slot, error } => {
900                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
901            }
902            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
903            BacktestError::SimulationError { error } => {
904                write!(f, "simulation error: {error}")
905            }
906            BacktestError::SessionNotFound { session_id } => {
907                write!(f, "session not found: {session_id}")
908            }
909            BacktestError::SessionOwnerMismatch => {
910                write!(f, "session owner mismatch")
911            }
912            BacktestError::SessionOwnershipBusy { reason } => {
913                write!(f, "session ownership busy: {reason}")
914            }
915        }
916    }
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922
923    fn range(start: u64, end: u64) -> AvailableRange {
924        AvailableRange {
925            bundle_start_slot: start,
926            bundle_start_slot_utc: None,
927            max_bundle_end_slot: Some(end),
928            max_bundle_end_slot_utc: None,
929            max_bundle_size: None,
930        }
931    }
932
933    #[test]
934    fn split_range_valid() {
935        // single
936        let ranges = vec![range(100, 300)];
937        assert_eq!(split_range(&ranges, 100, 300).unwrap(), vec![(100, 300)]);
938
939        // multi
940        let ranges = vec![range(100, 200), range(201, 300), range(301, 400)];
941        assert_eq!(
942            split_range(&ranges, 100, 300).unwrap(),
943            vec![(100, 200), (201, 300)]
944        );
945
946        // nested
947        // smaller bundles are contained within large ones but don't bridge the gap
948        // the range is still coverable and should not return an error
949        let ranges = vec![
950            range(100, 500),
951            range(110, 150),
952            range(150, 190),
953            range(501, 900),
954        ];
955        assert_eq!(
956            split_range(&ranges, 100, 900).unwrap(),
957            vec![(100, 500), (501, 900)]
958        );
959    }
960
961    #[test]
962    fn split_range_err() {
963        // start must be exact
964        let ranges = vec![range(200, 400)];
965        assert!(split_range(&ranges, 100, 400).is_err());
966
967        let ranges = vec![range(200, 400)];
968        assert!(split_range(&ranges, 300, 400).is_err());
969
970        // end not covered
971        let ranges = vec![range(100, 200)];
972        assert!(split_range(&ranges, 100, 300).is_err());
973
974        // gap
975        let ranges = vec![range(100, 200), range(210, 300)];
976        assert!(split_range(&ranges, 100, 300).is_err());
977
978        // inverted
979        let ranges = vec![range(100, 300)];
980        assert!(split_range(&ranges, 300, 100).is_err());
981    }
982}