1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fmt,
4};
5
6use base64::{
7 DecodeError as Base64DecodeError, Engine as _, engine::general_purpose::STANDARD as BASE64,
8};
9use serde::{Deserialize, Serialize};
10use solana_address::Address;
11
12#[derive(Debug, Serialize, Deserialize)]
14#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
15#[serde(tag = "method", content = "params", rename_all = "camelCase")]
16#[cfg_attr(feature = "ts-rs", ts(export))]
17pub enum BacktestRequest {
18 CreateBacktestSession(CreateBacktestSessionRequest),
19 Continue(ContinueParams),
20 ContinueSessionV1(ContinueSessionRequestV1),
21 CloseBacktestSession,
22 CloseSessionV1(CloseSessionRequestV1),
23 AttachBacktestSession {
24 session_id: String,
25 last_sequence: Option<u64>,
28 },
29 AttachParallelControlSessionV2 {
30 control_session_id: String,
31 #[serde(default)]
35 last_sequences: BTreeMap<String, u64>,
36 },
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
44#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
45#[serde(untagged)]
46#[cfg_attr(feature = "ts-rs", ts(export))]
47pub enum CreateBacktestSessionRequest {
48 V1(CreateBacktestSessionRequestV1),
49 V0(CreateSessionParams),
50}
51
52impl CreateBacktestSessionRequest {
53 pub fn into_request_options(self) -> CreateBacktestSessionRequestOptions {
54 match self {
55 Self::V0(request) => CreateBacktestSessionRequestOptions {
56 request,
57 parallel: false,
58 },
59 Self::V1(CreateBacktestSessionRequestV1 { request, parallel }) => {
60 CreateBacktestSessionRequestOptions { request, parallel }
61 }
62 }
63 }
64
65 pub fn into_request_and_parallel(self) -> (CreateSessionParams, bool) {
66 let options = self.into_request_options();
67 (options.request, options.parallel)
68 }
69}
70
71impl From<CreateSessionParams> for CreateBacktestSessionRequest {
72 fn from(value: CreateSessionParams) -> Self {
73 Self::V0(value)
74 }
75}
76
77impl From<CreateBacktestSessionRequestV1> for CreateBacktestSessionRequest {
78 fn from(value: CreateBacktestSessionRequestV1) -> Self {
79 Self::V1(value)
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
85#[serde(rename_all = "camelCase")]
86#[cfg_attr(feature = "ts-rs", ts(export))]
87pub struct CreateBacktestSessionRequestV1 {
88 #[serde(flatten)]
89 pub request: CreateSessionParams,
90 pub parallel: bool,
91}
92
93#[derive(Debug, Clone)]
94pub struct CreateBacktestSessionRequestOptions {
95 pub request: CreateSessionParams,
96 pub parallel: bool,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
100#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
101#[serde(rename_all = "camelCase")]
102#[cfg_attr(feature = "ts-rs", ts(export))]
103pub struct ContinueSessionRequestV1 {
104 pub session_id: String,
105 pub request: ContinueParams,
106}
107
108#[derive(Debug, Serialize, Deserialize)]
109#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
110#[serde(rename_all = "camelCase")]
111#[cfg_attr(feature = "ts-rs", ts(export))]
112pub struct CloseSessionRequestV1 {
113 pub session_id: String,
114}
115
116#[serde_with::serde_as]
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
120#[serde(rename_all = "camelCase")]
121pub struct CreateSessionParams {
122 pub start_slot: u64,
124 pub end_slot: u64,
126 #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
127 #[serde(default)]
128 #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
129 pub signer_filter: BTreeSet<Address>,
131 #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
132 #[serde(default)]
133 #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
134 pub preload_programs: BTreeSet<Address>,
136 #[serde(default)]
138 pub preload_account_bundles: Vec<String>,
139 #[serde(default)]
142 pub send_summary: bool,
143 #[serde(default)]
146 #[cfg_attr(feature = "ts-rs", ts(optional))]
147 pub capacity_wait_timeout_secs: Option<u16>,
148 #[serde(default)]
152 pub disconnect_timeout_secs: Option<u16>,
153 #[serde(default)]
158 pub extra_compute_units: Option<u32>,
159 #[serde(default)]
161 pub agents: Vec<AgentParams>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
167#[serde(rename_all = "camelCase")]
168#[cfg_attr(feature = "ts-rs", ts(export))]
169pub enum AgentType {
170 Arb,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
176#[serde(rename_all = "camelCase")]
177#[cfg_attr(feature = "ts-rs", ts(export))]
178pub struct ArbRouteParams {
179 pub base_mint: String,
180 pub temp_mint: String,
181 #[serde(default)]
182 pub buy_dexes: Vec<String>,
183 #[serde(default)]
184 pub sell_dexes: Vec<String>,
185 pub min_input: u64,
186 pub max_input: u64,
187 #[serde(default)]
188 pub min_profit: u64,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
194#[serde(rename_all = "camelCase")]
195#[cfg_attr(feature = "ts-rs", ts(export))]
196pub struct AgentParams {
197 pub agent_type: AgentType,
198 pub wallet: Option<String>,
199 pub keypair: Option<String>,
201 pub seed_sol_lamports: Option<u64>,
202 #[serde(default)]
203 pub seed_token_accounts: BTreeMap<String, u64>,
204 #[serde(default)]
205 pub arb_routes: Vec<ArbRouteParams>,
206}
207
208#[serde_with::serde_as]
210#[derive(Debug, Serialize, Deserialize, Default)]
211#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
212pub struct AccountModifications(
213 #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
214 #[serde(default)]
215 #[cfg_attr(feature = "ts-rs", ts(as = "BTreeMap<String, AccountData>"))]
216 pub BTreeMap<Address, AccountData>,
217);
218
219#[serde_with::serde_as]
221#[derive(Debug, Serialize, Deserialize)]
222#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
223#[serde(rename_all = "camelCase")]
224pub struct ContinueParams {
225 #[serde(default = "ContinueParams::default_advance_count")]
226 pub advance_count: u64,
228 #[serde(default)]
229 pub transactions: Vec<String>,
231 #[serde(default)]
232 pub modify_account_states: AccountModifications,
234}
235
236impl Default for ContinueParams {
237 fn default() -> Self {
238 Self {
239 advance_count: Self::default_advance_count(),
240 transactions: Vec::new(),
241 modify_account_states: AccountModifications(BTreeMap::new()),
242 }
243 }
244}
245
246impl ContinueParams {
247 pub fn default_advance_count() -> u64 {
248 1
249 }
250}
251
252#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
254#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
255#[serde(rename_all = "lowercase")]
256pub enum BinaryEncoding {
257 Base64,
258}
259
260impl BinaryEncoding {
261 pub fn encode(self, bytes: &[u8]) -> String {
262 match self {
263 Self::Base64 => BASE64.encode(bytes),
264 }
265 }
266
267 pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
268 match self {
269 Self::Base64 => BASE64.decode(data),
270 }
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
277#[serde(rename_all = "camelCase")]
278pub struct EncodedBinary {
279 pub data: String,
281 pub encoding: BinaryEncoding,
283}
284
285impl EncodedBinary {
286 pub fn new(data: String, encoding: BinaryEncoding) -> Self {
287 Self { data, encoding }
288 }
289
290 pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
291 Self {
292 data: encoding.encode(bytes),
293 encoding,
294 }
295 }
296
297 pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
298 self.encoding.decode(&self.data)
299 }
300}
301
302#[serde_with::serde_as]
304#[derive(Debug, Serialize, Deserialize)]
305#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
306#[serde(rename_all = "camelCase")]
307pub struct AccountData {
308 pub data: EncodedBinary,
310 pub executable: bool,
312 pub lamports: u64,
314 #[serde_as(as = "serde_with::DisplayFromStr")]
315 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
316 pub owner: Address,
318 pub space: u64,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
324#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
325#[serde(tag = "method", content = "params", rename_all = "camelCase")]
326#[cfg_attr(feature = "ts-rs", ts(export))]
327pub enum BacktestResponse {
328 SessionCreated {
329 session_id: String,
330 rpc_endpoint: String,
331 },
332 SessionAttached {
333 session_id: String,
334 rpc_endpoint: String,
335 },
336 SessionsCreated {
337 session_ids: Vec<String>,
338 },
339 SessionsCreatedV2 {
340 control_session_id: String,
341 session_ids: Vec<String>,
342 },
343 ParallelSessionAttachedV2 {
344 control_session_id: String,
345 session_ids: Vec<String>,
346 },
347 ReadyForContinue,
348 SlotNotification(u64),
349 Error(BacktestError),
350 Success,
351 Completed {
352 #[serde(skip_serializing_if = "Option::is_none")]
356 summary: Option<SessionSummary>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 agent_stats: Option<Vec<AgentStatsReport>>,
359 },
360 Status {
361 status: BacktestStatus,
362 },
363 SessionEventV1 {
364 session_id: String,
365 event: SessionEventV1,
366 },
367 SessionEventV2 {
368 session_id: String,
369 seq_id: u64,
370 event: SessionEventKind,
371 },
372}
373
374impl BacktestResponse {
375 pub fn is_completed(&self) -> bool {
376 matches!(self, BacktestResponse::Completed { .. })
377 }
378
379 pub fn is_terminal(&self) -> bool {
380 match self {
381 BacktestResponse::Completed { .. } => true,
382 BacktestResponse::Error(e) => matches!(
383 e,
384 BacktestError::NoMoreBlocks
385 | BacktestError::AdvanceSlotFailed { .. }
386 | BacktestError::Internal { .. }
387 ),
388 _ => false,
389 }
390 }
391}
392
393impl From<BacktestStatus> for BacktestResponse {
394 fn from(status: BacktestStatus) -> Self {
395 Self::Status { status }
396 }
397}
398
399impl From<String> for BacktestResponse {
400 fn from(message: String) -> Self {
401 BacktestError::Internal { error: message }.into()
402 }
403}
404
405impl From<&str> for BacktestResponse {
406 fn from(message: &str) -> Self {
407 BacktestError::Internal {
408 error: message.to_string(),
409 }
410 .into()
411 }
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
416#[serde(tag = "method", content = "params", rename_all = "camelCase")]
417#[cfg_attr(feature = "ts-rs", ts(export))]
418pub enum SessionEventV1 {
419 ReadyForContinue,
420 SlotNotification(u64),
421 Error(BacktestError),
422 Success,
423 Completed {
424 #[serde(skip_serializing_if = "Option::is_none")]
425 summary: Option<SessionSummary>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 agent_stats: Option<Vec<AgentStatsReport>>,
428 },
429 Status {
430 status: BacktestStatus,
431 },
432}
433
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 SessionEventKind {
439 ReadyForContinue,
440 SlotNotification(u64),
441 Error(BacktestError),
442 Success,
443 Completed {
444 #[serde(skip_serializing_if = "Option::is_none")]
445 summary: Option<SessionSummary>,
446 },
447 Status {
448 status: BacktestStatus,
449 },
450}
451
452impl SessionEventKind {
453 pub fn is_terminal(&self) -> bool {
454 match self {
455 Self::Completed { .. } => true,
456 Self::Error(e) => matches!(
457 e,
458 BacktestError::NoMoreBlocks
459 | BacktestError::AdvanceSlotFailed { .. }
460 | BacktestError::Internal { .. }
461 ),
462 _ => false,
463 }
464 }
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
470#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
471#[serde(rename_all = "camelCase")]
472#[cfg_attr(feature = "ts-rs", ts(export))]
473pub struct SequencedResponse {
474 pub seq_id: u64,
475 #[serde(flatten)]
476 pub response: BacktestResponse,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
482#[serde(rename_all = "camelCase")]
483pub enum BacktestStatus {
484 StartingRuntime,
486 DecodedTransactions,
487 AppliedAccountModifications,
488 ReadyToExecuteUserTransactions,
489 ExecutedUserTransactions,
490 ExecutingBlockTransactions,
491 ExecutedBlockTransactions,
492 ProgramAccountsLoaded,
493}
494
495#[derive(Debug, Clone, Default, Serialize, Deserialize)]
497#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
498#[serde(rename_all = "camelCase")]
499#[cfg_attr(feature = "ts-rs", ts(export))]
500pub struct AgentStatsReport {
501 pub name: String,
502 pub slots_processed: u64,
503 pub opportunities_found: u64,
504 pub opportunities_skipped: u64,
505 pub no_routes: u64,
506 pub txs_produced: u64,
507 pub expected_gain_by_mint: BTreeMap<String, i64>,
509 #[serde(default)]
511 pub txs_submitted: u64,
512 #[serde(default)]
514 pub txs_failed: u64,
515 #[serde(default)]
517 pub txs_simulation_rejected: u64,
518 #[serde(default)]
520 pub txs_simulation_failed: u64,
521}
522
523#[derive(Debug, Clone, Default, Serialize, Deserialize)]
525#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
526#[serde(rename_all = "camelCase")]
527pub struct SessionSummary {
528 pub correct_simulation: usize,
531 pub incorrect_simulation: usize,
534 pub execution_errors: usize,
536 pub balance_diff: usize,
538 pub log_diff: usize,
540}
541
542impl SessionSummary {
543 pub fn has_deviations(&self) -> bool {
545 self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
546 }
547
548 pub fn total_transactions(&self) -> usize {
550 self.correct_simulation
551 + self.incorrect_simulation
552 + self.execution_errors
553 + self.balance_diff
554 + self.log_diff
555 }
556}
557
558impl std::fmt::Display for SessionSummary {
559 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560 let total = self.total_transactions();
561 write!(
562 f,
563 "Session summary: {total} transactions\n\
564 \x20 - {} correct simulation\n\
565 \x20 - {} incorrect simulation\n\
566 \x20 - {} execution errors\n\
567 \x20 - {} balance diffs\n\
568 \x20 - {} log diffs",
569 self.correct_simulation,
570 self.incorrect_simulation,
571 self.execution_errors,
572 self.balance_diff,
573 self.log_diff,
574 )
575 }
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize)]
580#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
581#[serde(rename_all = "camelCase")]
582pub enum BacktestError {
583 InvalidTransactionEncoding {
584 index: usize,
585 error: String,
586 },
587 InvalidTransactionFormat {
588 index: usize,
589 error: String,
590 },
591 InvalidAccountEncoding {
592 address: String,
593 encoding: BinaryEncoding,
594 error: String,
595 },
596 InvalidAccountOwner {
597 address: String,
598 error: String,
599 },
600 InvalidAccountPubkey {
601 address: String,
602 error: String,
603 },
604 NoMoreBlocks,
605 AdvanceSlotFailed {
606 slot: u64,
607 error: String,
608 },
609 InvalidRequest {
610 error: String,
611 },
612 Internal {
613 error: String,
614 },
615 InvalidBlockhashFormat {
616 slot: u64,
617 error: String,
618 },
619 InitializingSysvarsFailed {
620 slot: u64,
621 error: String,
622 },
623 ClerkError {
624 error: String,
625 },
626 SimulationError {
627 error: String,
628 },
629 SessionNotFound {
630 session_id: String,
631 },
632 SessionOwnerMismatch,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
637pub struct AvailableRange {
638 pub bundle_start_slot: u64,
639 pub bundle_start_slot_utc: Option<String>,
640 pub max_bundle_end_slot: Option<u64>,
641 pub max_bundle_end_slot_utc: Option<String>,
642}
643
644pub fn split_range(
650 ranges: &[AvailableRange],
651 requested_start: u64,
652 requested_end: u64,
653) -> Result<Vec<(u64, u64)>, String> {
654 if requested_end < requested_start {
655 return Err(format!(
656 "invalid range: start_slot {requested_start} > end_slot {requested_end}"
657 ));
658 }
659
660 let mut candidates: Vec<(u64, u64)> = ranges
661 .iter()
662 .filter_map(|r| Some((r.bundle_start_slot, r.max_bundle_end_slot?)))
663 .filter(|(start, end)| end >= start)
664 .collect();
665
666 candidates.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
667 candidates.dedup_by_key(|(start, _)| *start);
668
669 if candidates.is_empty() {
670 return Err("no available bundle ranges found on server".to_string());
671 }
672
673 let mut non_overlapping: Vec<(u64, u64)> = Vec::with_capacity(candidates.len());
674 for (i, (start, mut end)) in candidates.iter().copied().enumerate() {
675 if let Some((next_start, _)) = candidates.get(i + 1).copied()
676 && next_start <= end
677 {
678 end = next_start.saturating_sub(1);
679 }
680 if end >= start {
681 non_overlapping.push((start, end));
682 }
683 }
684
685 let anchor = non_overlapping
686 .iter()
687 .enumerate()
688 .rev()
689 .find(|(_, (s, e))| *s <= requested_start && *e >= requested_start)
690 .map(|(i, _)| i)
691 .ok_or_else(|| {
692 format!("start_slot {requested_start} is not covered by any available bundle range")
693 })?;
694
695 let mut result = Vec::new();
696 for (start, range_end) in non_overlapping.into_iter().skip(anchor) {
697 if start > requested_end {
698 break;
699 }
700 let end = range_end.min(requested_end);
701 if end >= start {
702 result.push((start, end));
703 }
704 if end == requested_end {
705 break;
706 }
707 }
708
709 if result.is_empty() {
710 return Err(format!(
711 "no available bundle ranges intersect requested range [{requested_start}-{requested_end}]"
712 ));
713 }
714
715 Ok(result)
716}
717
718impl From<BacktestError> for BacktestResponse {
719 fn from(error: BacktestError) -> Self {
720 Self::Error(error)
721 }
722}
723
724impl std::error::Error for BacktestError {}
725
726impl fmt::Display for BacktestError {
727 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
728 match self {
729 BacktestError::InvalidTransactionEncoding { index, error } => {
730 write!(f, "invalid transaction encoding at index {index}: {error}")
731 }
732 BacktestError::InvalidTransactionFormat { index, error } => {
733 write!(f, "invalid transaction format at index {index}: {error}")
734 }
735 BacktestError::InvalidAccountEncoding {
736 address,
737 encoding,
738 error,
739 } => write!(
740 f,
741 "invalid encoding for account {address} ({encoding:?}): {error}"
742 ),
743 BacktestError::InvalidAccountOwner { address, error } => {
744 write!(f, "invalid owner for account {address}: {error}")
745 }
746 BacktestError::InvalidAccountPubkey { address, error } => {
747 write!(f, "invalid account pubkey {address}: {error}")
748 }
749 BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
750 BacktestError::AdvanceSlotFailed { slot, error } => {
751 write!(f, "failed to advance to slot {slot}: {error}")
752 }
753 BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
754 BacktestError::Internal { error } => write!(f, "internal error: {error}"),
755 BacktestError::InvalidBlockhashFormat { slot, error } => {
756 write!(f, "invalid blockhash at slot {slot}: {error}")
757 }
758 BacktestError::InitializingSysvarsFailed { slot, error } => {
759 write!(f, "failed to initialize sysvars at slot {slot}: {error}")
760 }
761 BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
762 BacktestError::SimulationError { error } => {
763 write!(f, "simulation error: {error}")
764 }
765 BacktestError::SessionNotFound { session_id } => {
766 write!(f, "session not found: {session_id}")
767 }
768 BacktestError::SessionOwnerMismatch => {
769 write!(f, "session owner mismatch")
770 }
771 }
772 }
773}