Skip to main content

simulator_api/
lib.rs

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