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