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        #[serde(default, skip_serializing_if = "Option::is_none")]
441        task_id: Option<String>,
442    },
443    SessionAttached {
444        session_id: String,
445        rpc_endpoint: String,
446        #[serde(default, skip_serializing_if = "Option::is_none")]
447        task_id: Option<String>,
448    },
449    SessionsCreated {
450        session_ids: Vec<String>,
451    },
452    SessionsCreatedV2 {
453        control_session_id: String,
454        session_ids: Vec<String>,
455        #[serde(default)]
456        task_ids: Vec<Option<String>>,
457    },
458    ParallelSessionAttachedV2 {
459        control_session_id: String,
460        session_ids: Vec<String>,
461        #[serde(default)]
462        task_ids: Vec<Option<String>>,
463    },
464    ReadyForContinue,
465    SlotNotification(u64),
466    Paused(PausedEvent),
467    DiscoveryBatch(DiscoveryBatchEvent),
468    Error(BacktestError),
469    Success,
470    Completed {
471        /// Session summary with transaction statistics.
472        /// The session always computes this summary, but management may omit it from
473        /// client-facing responses unless `send_summary` was requested at session creation.
474        #[serde(skip_serializing_if = "Option::is_none")]
475        summary: Option<SessionSummary>,
476        #[serde(default, skip_serializing_if = "Option::is_none")]
477        agent_stats: Option<Vec<AgentStatsReport>>,
478    },
479    Status {
480        status: BacktestStatus,
481    },
482    SessionEventV1 {
483        session_id: String,
484        event: SessionEventV1,
485    },
486    SessionEventV2 {
487        session_id: String,
488        seq_id: u64,
489        event: SessionEventKind,
490    },
491}
492
493impl BacktestResponse {
494    pub fn is_completed(&self) -> bool {
495        matches!(self, BacktestResponse::Completed { .. })
496    }
497
498    pub fn is_terminal(&self) -> bool {
499        match self {
500            BacktestResponse::Completed { .. } => true,
501            BacktestResponse::Error(e) => matches!(
502                e,
503                BacktestError::NoMoreBlocks
504                    | BacktestError::AdvanceSlotFailed { .. }
505                    | BacktestError::Internal { .. }
506            ),
507            _ => false,
508        }
509    }
510}
511
512impl From<BacktestStatus> for BacktestResponse {
513    fn from(status: BacktestStatus) -> Self {
514        Self::Status { status }
515    }
516}
517
518impl From<String> for BacktestResponse {
519    fn from(message: String) -> Self {
520        BacktestError::Internal { error: message }.into()
521    }
522}
523
524impl From<&str> for BacktestResponse {
525    fn from(message: &str) -> Self {
526        BacktestError::Internal {
527            error: message.to_string(),
528        }
529        .into()
530    }
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
534#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
535#[serde(tag = "method", content = "params", rename_all = "camelCase")]
536#[cfg_attr(feature = "ts-rs", ts(export))]
537pub enum SessionEventV1 {
538    ReadyForContinue,
539    SlotNotification(u64),
540    Paused(PausedEvent),
541    DiscoveryBatch(DiscoveryBatchEvent),
542    Error(BacktestError),
543    Success,
544    Completed {
545        #[serde(skip_serializing_if = "Option::is_none")]
546        summary: Option<SessionSummary>,
547        #[serde(default, skip_serializing_if = "Option::is_none")]
548        agent_stats: Option<Vec<AgentStatsReport>>,
549    },
550    Status {
551        status: BacktestStatus,
552    },
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
556#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
557#[serde(tag = "method", content = "params", rename_all = "camelCase")]
558#[cfg_attr(feature = "ts-rs", ts(export))]
559pub enum SessionEventKind {
560    ReadyForContinue,
561    SlotNotification(u64),
562    Paused(PausedEvent),
563    DiscoveryBatch(DiscoveryBatchEvent),
564    Error(BacktestError),
565    Success,
566    Completed {
567        #[serde(skip_serializing_if = "Option::is_none")]
568        summary: Option<SessionSummary>,
569    },
570    Status {
571        status: BacktestStatus,
572    },
573}
574
575impl SessionEventKind {
576    pub fn is_terminal(&self) -> bool {
577        match self {
578            Self::Completed { .. } => true,
579            Self::Error(e) => matches!(
580                e,
581                BacktestError::NoMoreBlocks
582                    | BacktestError::AdvanceSlotFailed { .. }
583                    | BacktestError::Internal { .. }
584            ),
585            _ => false,
586        }
587    }
588}
589
590/// Wire format wrapper for responses sent over the control websocket.
591/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
592#[derive(Debug, Clone, Serialize, Deserialize)]
593#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
594#[serde(rename_all = "camelCase")]
595#[cfg_attr(feature = "ts-rs", ts(export))]
596pub struct SequencedResponse {
597    pub seq_id: u64,
598    #[serde(flatten)]
599    pub response: BacktestResponse,
600}
601
602/// High-level progress states during a `Continue` call.
603#[derive(Debug, Clone, Serialize, Deserialize)]
604#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
605#[serde(rename_all = "camelCase")]
606pub enum BacktestStatus {
607    /// Runtime startup is in progress.
608    StartingRuntime,
609    DecodedTransactions,
610    AppliedAccountModifications,
611    ReadyToExecuteUserTransactions,
612    ExecutedUserTransactions,
613    ExecutingBlockTransactions,
614    ExecutedBlockTransactions,
615    ProgramAccountsLoaded,
616}
617
618impl std::fmt::Display for BacktestStatus {
619    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620        let s = match self {
621            Self::StartingRuntime => "starting runtime",
622            Self::DecodedTransactions => "decoded transactions",
623            Self::AppliedAccountModifications => "applied account modifications",
624            Self::ReadyToExecuteUserTransactions => "ready to execute user transactions",
625            Self::ExecutedUserTransactions => "executed user transactions",
626            Self::ExecutingBlockTransactions => "executing block transactions",
627            Self::ExecutedBlockTransactions => "executed block transactions",
628            Self::ProgramAccountsLoaded => "program accounts loaded",
629        };
630        f.write_str(s)
631    }
632}
633
634/// Structured stats reported by an agent during a backtest session.
635#[derive(Debug, Clone, Default, Serialize, Deserialize)]
636#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
637#[serde(rename_all = "camelCase")]
638#[cfg_attr(feature = "ts-rs", ts(export))]
639pub struct AgentStatsReport {
640    pub name: String,
641    pub slots_processed: u64,
642    pub opportunities_found: u64,
643    pub opportunities_skipped: u64,
644    pub no_routes: u64,
645    pub txs_produced: u64,
646    /// Cumulative expected profit per base mint, keyed by mint address.
647    pub expected_gain_by_mint: BTreeMap<String, i64>,
648    /// Transactions successfully executed by the sidecar.
649    #[serde(default)]
650    pub txs_submitted: u64,
651    /// Transactions that failed execution.
652    #[serde(default)]
653    pub txs_failed: u64,
654    /// Transactions rejected by preflight simulation (unprofitable).
655    #[serde(default)]
656    pub txs_simulation_rejected: u64,
657    /// Preflight simulation RPC calls that errored.
658    #[serde(default)]
659    pub txs_simulation_failed: u64,
660}
661
662/// Summary of transaction execution statistics for a completed backtest session.
663#[derive(Debug, Clone, Default, Serialize, Deserialize)]
664#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
665#[serde(rename_all = "camelCase")]
666pub struct SessionSummary {
667    /// Number of simulations where simulator outcome matched on-chain outcome
668    /// (`true_success + true_failure`).
669    pub correct_simulation: usize,
670    /// Number of simulations where simulator outcome did not match on-chain outcome
671    /// (`false_success + false_failure`).
672    pub incorrect_simulation: usize,
673    /// Number of transactions that had execution errors in simulation.
674    pub execution_errors: usize,
675    /// Number of transactions with different balance diffs.
676    pub balance_diff: usize,
677    /// Number of transactions with different log diffs.
678    pub log_diff: usize,
679}
680
681impl SessionSummary {
682    /// Returns true if there were any execution deviations (errors or mismatched results).
683    pub fn has_deviations(&self) -> bool {
684        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
685    }
686
687    /// Total number of transactions processed.
688    pub fn total_transactions(&self) -> usize {
689        self.correct_simulation
690            + self.incorrect_simulation
691            + self.execution_errors
692            + self.balance_diff
693            + self.log_diff
694    }
695}
696
697impl std::fmt::Display for SessionSummary {
698    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
699        let total = self.total_transactions();
700        write!(
701            f,
702            "Session summary: {total} transactions\n\
703             \x20  - {} correct simulation\n\
704             \x20  - {} incorrect simulation\n\
705             \x20  - {} execution errors\n\
706             \x20  - {} balance diffs\n\
707             \x20  - {} log diffs",
708            self.correct_simulation,
709            self.incorrect_simulation,
710            self.execution_errors,
711            self.balance_diff,
712            self.log_diff,
713        )
714    }
715}
716
717/// Error variants surfaced to backtest RPC clients.
718#[derive(Debug, Clone, Serialize, Deserialize)]
719#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
720#[serde(rename_all = "camelCase")]
721pub enum BacktestError {
722    InvalidTransactionEncoding {
723        index: usize,
724        error: String,
725    },
726    InvalidTransactionFormat {
727        index: usize,
728        error: String,
729    },
730    InvalidAccountEncoding {
731        address: String,
732        encoding: BinaryEncoding,
733        error: String,
734    },
735    InvalidAccountOwner {
736        address: String,
737        error: String,
738    },
739    InvalidAccountPubkey {
740        address: String,
741        error: String,
742    },
743    NoMoreBlocks,
744    AdvanceSlotFailed {
745        slot: u64,
746        error: String,
747    },
748    InvalidRequest {
749        error: String,
750    },
751    Internal {
752        error: String,
753    },
754    InvalidBlockhashFormat {
755        slot: u64,
756        error: String,
757    },
758    InitializingSysvarsFailed {
759        slot: u64,
760        error: String,
761    },
762    ClerkError {
763        error: String,
764    },
765    SimulationError {
766        error: String,
767    },
768    SessionNotFound {
769        session_id: String,
770    },
771    SessionOwnerMismatch,
772    /// Session ownership is in transition (e.g. the previous manager is
773    /// shutting down, or another attach raced this one). Clients should retry
774    /// the attach within their reconnect budget; the route is expected to
775    /// become claimable shortly.
776    SessionOwnershipBusy {
777        reason: String,
778    },
779}
780
781/// One contiguous block range available on the history clerk.
782#[derive(Debug, Clone, Serialize, Deserialize)]
783pub struct AvailableRange {
784    pub bundle_start_slot: u64,
785    pub bundle_start_slot_utc: Option<String>,
786    pub max_bundle_end_slot: Option<u64>,
787    pub max_bundle_end_slot_utc: Option<String>,
788    pub max_bundle_size: Option<u64>,
789}
790
791/// Split a user-requested `[start_slot, end_slot]` range across the available
792/// bundle ranges, returning a list of non-overlapping `(start, end)` pairs — one
793/// per bundle that intersects the requested range.
794///
795/// Returns an error if the requested start slot is not covered by any range.
796pub fn split_range(
797    ranges: &[AvailableRange],
798    requested_start: u64,
799    requested_end: u64,
800) -> Result<Vec<(u64, u64)>, String> {
801    if requested_end < requested_start {
802        return Err(format!(
803            "invalid range: start_slot {requested_start} > end_slot {requested_end}"
804        ));
805    }
806
807    let mut candidates: Vec<(u64, u64)> = ranges
808        .iter()
809        .filter_map(|r| Some((r.bundle_start_slot, r.max_bundle_end_slot?)))
810        .filter(|(start, end)| {
811            end > start
812                && *start >= requested_start
813                && *start < requested_end
814                && *end > requested_start
815        })
816        .collect();
817
818    candidates.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
819    candidates.dedup_by_key(|(start, _)| *start);
820
821    if candidates.is_empty() || candidates.first().unwrap().0 != requested_start {
822        return Err(format!(
823            "start_slot {requested_start} is not covered by any available bundle range"
824        ));
825    }
826
827    let mut result: Vec<(u64, u64)> = Vec::new();
828    let mut current_slot = requested_start;
829    let mut i = 0;
830    let mut best_end = 0u64;
831
832    while current_slot <= requested_end {
833        while i < candidates.len() && candidates[i].0 <= current_slot {
834            best_end = best_end.max(candidates[i].1);
835            i += 1;
836        }
837        if best_end < current_slot {
838            return Err(format!("gap in coverage at slot {current_slot}"));
839        }
840        let range_end = best_end.min(requested_end);
841        result.push((current_slot, range_end));
842        current_slot = range_end + 1;
843    }
844
845    Ok(result)
846}
847
848impl From<BacktestError> for BacktestResponse {
849    fn from(error: BacktestError) -> Self {
850        Self::Error(error)
851    }
852}
853
854impl std::error::Error for BacktestError {}
855
856impl fmt::Display for BacktestError {
857    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
858        match self {
859            BacktestError::InvalidTransactionEncoding { index, error } => {
860                write!(f, "invalid transaction encoding at index {index}: {error}")
861            }
862            BacktestError::InvalidTransactionFormat { index, error } => {
863                write!(f, "invalid transaction format at index {index}: {error}")
864            }
865            BacktestError::InvalidAccountEncoding {
866                address,
867                encoding,
868                error,
869            } => write!(
870                f,
871                "invalid encoding for account {address} ({encoding:?}): {error}"
872            ),
873            BacktestError::InvalidAccountOwner { address, error } => {
874                write!(f, "invalid owner for account {address}: {error}")
875            }
876            BacktestError::InvalidAccountPubkey { address, error } => {
877                write!(f, "invalid account pubkey {address}: {error}")
878            }
879            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
880            BacktestError::AdvanceSlotFailed { slot, error } => {
881                write!(f, "failed to advance to slot {slot}: {error}")
882            }
883            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
884            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
885            BacktestError::InvalidBlockhashFormat { slot, error } => {
886                write!(f, "invalid blockhash at slot {slot}: {error}")
887            }
888            BacktestError::InitializingSysvarsFailed { slot, error } => {
889                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
890            }
891            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
892            BacktestError::SimulationError { error } => {
893                write!(f, "simulation error: {error}")
894            }
895            BacktestError::SessionNotFound { session_id } => {
896                write!(f, "session not found: {session_id}")
897            }
898            BacktestError::SessionOwnerMismatch => {
899                write!(f, "session owner mismatch")
900            }
901            BacktestError::SessionOwnershipBusy { reason } => {
902                write!(f, "session ownership busy: {reason}")
903            }
904        }
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911
912    fn range(start: u64, end: u64) -> AvailableRange {
913        AvailableRange {
914            bundle_start_slot: start,
915            bundle_start_slot_utc: None,
916            max_bundle_end_slot: Some(end),
917            max_bundle_end_slot_utc: None,
918            max_bundle_size: None,
919        }
920    }
921
922    #[test]
923    fn split_range_valid() {
924        // single
925        let ranges = vec![range(100, 300)];
926        assert_eq!(split_range(&ranges, 100, 300).unwrap(), vec![(100, 300)]);
927
928        // multi
929        let ranges = vec![range(100, 200), range(201, 300), range(301, 400)];
930        assert_eq!(
931            split_range(&ranges, 100, 300).unwrap(),
932            vec![(100, 200), (201, 300)]
933        );
934
935        // nested
936        // smaller bundles are contained within large ones but don't bridge the gap
937        // the range is still coverable and should not return an error
938        let ranges = vec![
939            range(100, 500),
940            range(110, 150),
941            range(150, 190),
942            range(501, 900),
943        ];
944        assert_eq!(
945            split_range(&ranges, 100, 900).unwrap(),
946            vec![(100, 500), (501, 900)]
947        );
948    }
949
950    #[test]
951    fn split_range_err() {
952        // start must be exact
953        let ranges = vec![range(200, 400)];
954        assert!(split_range(&ranges, 100, 400).is_err());
955
956        let ranges = vec![range(200, 400)];
957        assert!(split_range(&ranges, 300, 400).is_err());
958
959        // end not covered
960        let ranges = vec![range(100, 200)];
961        assert!(split_range(&ranges, 100, 300).is_err());
962
963        // gap
964        let ranges = vec![range(100, 200), range(210, 300)];
965        assert!(split_range(&ranges, 100, 300).is_err());
966
967        // inverted
968        let ranges = vec![range(100, 300)];
969        assert!(split_range(&ranges, 300, 100).is_err());
970    }
971}