1use crate::compat::sleep;
11use crate::performance_metrics::{
12 BacktestClosedTrade, BacktestEquityPoint, PerformanceBenchmark, PerformanceMetrics,
13 PerformanceMetricsSnapshot, PerformanceTracker,
14};
15use crate::poll_guard::{PollGuard, PollOutcome};
16use bot_core::{
17 CancelAll, CancelOrder, ClientOrderId, Command, Event, Exchange, ExchangeError, ExchangeHealth,
18 ExchangeInstance, Fill, InstrumentId, OrderAcceptedEvent, OrderCanceledEvent,
19 OrderCompletedEvent, OrderFilledEvent, OrderInput, OrderRejectedEvent, PlaceOrder,
20 PlaceOrderResult, Position, Qty, Quote, QuoteEvent,
21};
22use futures::channel::mpsc;
23use rust_decimal::Decimal;
24use std::collections::{HashMap, VecDeque};
25use std::sync::Arc;
26use std::time::Duration;
27
28#[cfg(feature = "native")]
29use crate::account_syncer::{AccountSyncer, AccountSyncerConfig};
30#[cfg(feature = "native")]
31use crate::sync_traits::{AccountSync, TradeSync};
32#[cfg(feature = "native")]
33use crate::trade_syncer::{TradeSyncer, TradeSyncerConfig};
34use crate::Engine;
35
36use serde::Serialize;
37
38const MAX_DEFERRED_ACTION_LIMIT_COMMANDS: usize = 1024;
39const MAX_DEFERRED_ACTION_LIMIT_PER_LOOP: usize = 4;
40
41#[derive(Debug, Clone)]
42struct DeferredActionLimitCommand {
43 retry_at_ms: i64,
44 command: Command,
45}
46
47#[derive(Debug, Clone, Serialize)]
49pub struct BacktestFill {
50 pub ts_ms: i64,
52 pub price: String,
54 pub qty: String,
56 pub side: String,
58 pub fee: String,
60 pub fee_amount: String,
62 pub fee_currency: String,
64}
65
66#[derive(Debug, Clone, Serialize)]
68pub struct BacktestPositionSummary {
69 pub instrument: String,
71 pub final_position_qty: String,
73 pub avg_entry_price: Option<String>,
75 pub realized_pnl: String,
77 pub unrealized_pnl: Option<String>,
79 pub total_fees: String,
81 pub net_pnl: String,
83}
84
85#[derive(Debug, Clone, Serialize)]
87pub struct BacktestResult {
88 pub fills: Vec<BacktestFill>,
90 pub trade_count: usize,
92 pub metrics: PerformanceMetrics,
94 pub benchmark: PerformanceBenchmark,
96 pub equity_curve: Vec<BacktestEquityPoint>,
98 pub closed_trades: Vec<BacktestClosedTrade>,
100 pub final_position_qty: String,
102 pub avg_entry_price: Option<String>,
104 pub realized_pnl: String,
106 pub unrealized_pnl: Option<String>,
108 pub total_fees: String,
110 pub total_volume: String,
112 pub net_pnl: String,
114 #[serde(default)]
115 pub positions: Vec<BacktestPositionSummary>,
117 pub exit_reason: Option<String>,
119}
120
121#[derive(Debug)]
123pub enum PollResult {
124 Fills {
126 instance: ExchangeInstance,
128 fills: Vec<Fill>,
130 },
131 Quotes {
133 instance: ExchangeInstance,
135 quotes: Vec<Quote>,
137 },
138 ExchangeHealth {
140 instance: ExchangeInstance,
142 health: ExchangeHealth,
144 },
145 Error {
147 instance: ExchangeInstance,
149 error: String,
151 },
152}
153
154#[derive(Debug, Clone)]
156pub struct RunnerConfig {
157 pub min_poll_delay_ms: u64,
159 pub initial_backoff_ms: u64,
161 pub max_backoff_ms: u64,
163 pub backoff_multiplier: f64,
165 pub quote_poll_interval_ms: u64,
167 pub cleanup_delay_ms: u64,
169 pub metrics_mode: String,
171 pub metrics_starting_balance_usdc: Option<Decimal>,
173}
174
175impl Default for RunnerConfig {
176 fn default() -> Self {
177 Self {
178 min_poll_delay_ms: 500,
179 initial_backoff_ms: 1000,
180 max_backoff_ms: 30_000,
181 backoff_multiplier: 2.0,
182 quote_poll_interval_ms: 1000,
183 cleanup_delay_ms: 5000, metrics_mode: "live".to_string(),
185 metrics_starting_balance_usdc: None,
186 }
187 }
188}
189
190#[derive(Debug, Default, Clone)]
192pub struct TradingStats {
193 pub orders_placed: u32,
195 pub orders_filled: u32,
197 pub volume_traded: Decimal,
199 pub total_fees: Decimal,
201 pub realized_pnl: Decimal,
203 pub last_meta_log_ms: i64,
205}
206
207pub struct EngineRunner {
209 config: RunnerConfig,
210 engine: Engine,
211 exchanges: HashMap<ExchangeInstance, Arc<dyn Exchange>>,
212 instruments: Vec<InstrumentId>,
213 shutdown_rx: Option<mpsc::UnboundedReceiver<()>>,
214 shutdown_tx: mpsc::UnboundedSender<()>,
215 should_shutdown: bool,
217 shutdown_reason: Option<String>,
219 #[cfg(feature = "native")]
221 trade_syncer: Option<Box<dyn TradeSync>>,
222 #[cfg(feature = "native")]
224 account_syncer: Option<Box<dyn AccountSync>>,
225 current_mid_price: Option<Decimal>,
227 stats: TradingStats,
229 performance_tracker: PerformanceTracker,
231 fills_guard: PollGuard,
233 quotes_guard: PollGuard,
235 deferred_action_limit_commands: VecDeque<DeferredActionLimitCommand>,
237}
238
239impl EngineRunner {
240 pub fn new(engine: Engine, config: RunnerConfig) -> Self {
242 let (shutdown_tx, shutdown_rx) = mpsc::unbounded();
243 let fills_guard = PollGuard::new("fills", &config);
244 let quotes_guard = PollGuard::new("quotes", &config);
245
246 Self {
247 performance_tracker: PerformanceTracker::new(
248 config.metrics_mode.clone(),
249 config.metrics_starting_balance_usdc,
250 None,
251 ),
252 config,
253 engine,
254 exchanges: HashMap::new(),
255 instruments: Vec::new(),
256 shutdown_rx: Some(shutdown_rx),
257 shutdown_tx,
258 should_shutdown: false,
259 shutdown_reason: None,
260 #[cfg(feature = "native")]
261 trade_syncer: None,
262 #[cfg(feature = "native")]
263 account_syncer: None,
264 current_mid_price: None,
265 stats: TradingStats::default(),
266 fills_guard,
267 quotes_guard,
268 deferred_action_limit_commands: VecDeque::new(),
269 }
270 }
271
272 #[cfg(feature = "native")]
274 pub fn with_syncer(mut self, syncer_config: TradeSyncerConfig) -> Self {
275 match TradeSyncer::new(syncer_config) {
276 Ok(syncer) => {
277 tracing::info!("[EngineRunner] Trade syncer enabled");
278 self.trade_syncer = Some(Box::new(syncer));
279 }
280 Err(e) => {
281 tracing::warn!("[EngineRunner] Failed to create trade syncer: {}", e);
282 }
283 }
284 self
285 }
286
287 #[cfg(feature = "native")]
289 pub fn set_trade_syncer(&mut self, syncer: Box<dyn TradeSync>) {
290 self.trade_syncer = Some(syncer);
291 }
292
293 #[cfg(feature = "native")]
295 pub fn with_account_syncer(mut self, syncer_config: AccountSyncerConfig) -> Self {
296 match AccountSyncer::new(syncer_config) {
297 Ok(syncer) => {
298 tracing::info!("[EngineRunner] Account syncer enabled");
299 self.account_syncer = Some(Box::new(syncer));
300 }
301 Err(e) => {
302 tracing::warn!("[EngineRunner] Failed to create account syncer: {}", e);
303 }
304 }
305 self
306 }
307
308 #[cfg(feature = "native")]
310 pub fn set_account_syncer(&mut self, syncer: Box<dyn AccountSync>) {
311 self.account_syncer = Some(syncer);
312 }
313
314 pub fn add_exchange(&mut self, exchange: Arc<dyn Exchange>) {
316 let instance = exchange.instance();
317 self.exchanges.insert(instance, exchange);
318 }
319
320 pub fn add_instrument(&mut self, instrument: InstrumentId) {
322 self.performance_tracker.set_instrument(instrument.clone());
323 self.instruments.push(instrument);
324 }
325
326 pub fn shutdown_handle(&self) -> mpsc::UnboundedSender<()> {
328 self.shutdown_tx.clone()
329 }
330
331 pub fn engine(&self) -> &Engine {
334 &self.engine
335 }
336
337 pub fn shutdown_reason(&self) -> Option<&str> {
339 self.shutdown_reason.as_deref()
340 }
341
342 pub fn get_backtest_results(&self, instrument: &InstrumentId) -> BacktestResult {
345 let fills = self.engine.get_fills();
346 let positions = self.tracked_positions();
347 let metrics_snapshot = self.performance_tracker.snapshot(fills, &positions);
348 let primary_position = self.engine.position(instrument);
349
350 let mut total_volume = Decimal::ZERO;
352 for fill in fills {
353 let notional = fill.qty.0 * fill.price.0;
354 total_volume += notional;
355 }
356
357 let total_realized_pnl: Decimal = positions.iter().map(|p| p.realized_pnl).sum();
358 let total_unrealized_pnl: Decimal = positions
359 .iter()
360 .map(|p| p.unrealized_pnl.unwrap_or_default())
361 .sum();
362 let total_fees: Decimal = positions.iter().map(|p| p.total_fees).sum();
363 let total_net_pnl: Decimal = positions.iter().map(Position::current_pnl).sum();
364 let unrealized_pnl = positions
365 .iter()
366 .any(|p| p.unrealized_pnl.is_some())
367 .then_some(total_unrealized_pnl);
368
369 let position_summaries: Vec<BacktestPositionSummary> = self
370 .instruments
371 .iter()
372 .zip(positions.iter())
373 .map(|(instrument, position)| BacktestPositionSummary {
374 instrument: instrument.to_string(),
375 final_position_qty: position.qty.to_string(),
376 avg_entry_price: position.avg_entry_px.map(|p| p.0.to_string()),
377 realized_pnl: position.realized_pnl.to_string(),
378 unrealized_pnl: position.unrealized_pnl.map(|p| p.to_string()),
379 total_fees: position.total_fees.to_string(),
380 net_pnl: position.current_pnl().to_string(),
381 })
382 .collect();
383
384 let backtest_fills: Vec<BacktestFill> = fills
386 .iter()
387 .map(|f| BacktestFill {
388 ts_ms: f.ts,
389 price: f.price.0.to_string(),
390 qty: f.qty.0.to_string(),
391 side: format!("{:?}", f.side),
392 fee: f.fee.asset.0.clone(),
393 fee_amount: f.fee.amount.to_string(),
394 fee_currency: f.fee.asset.0.clone(),
395 })
396 .collect();
397
398 BacktestResult {
399 trade_count: backtest_fills.len(),
400 fills: backtest_fills,
401 metrics: metrics_snapshot.metrics,
402 benchmark: metrics_snapshot.benchmark,
403 equity_curve: self.performance_tracker.equity_curve(),
404 closed_trades: self.performance_tracker.closed_trades(fills),
405 final_position_qty: primary_position.qty.to_string(),
406 avg_entry_price: primary_position.avg_entry_px.map(|p| p.0.to_string()),
407 realized_pnl: total_realized_pnl.to_string(),
408 unrealized_pnl: unrealized_pnl.map(|p| p.to_string()),
409 total_fees: total_fees.to_string(),
410 total_volume: total_volume.to_string(),
411 net_pnl: total_net_pnl.to_string(),
412 positions: position_summaries,
413 exit_reason: self.shutdown_reason.clone(),
414 }
415 }
416
417 fn tracked_positions(&self) -> Vec<Position> {
418 self.instruments
419 .iter()
420 .map(|instrument| self.engine.position(instrument))
421 .collect()
422 }
423
424 fn current_net_pnl(&self) -> Decimal {
425 self.tracked_positions()
426 .iter()
427 .map(Position::current_pnl)
428 .sum()
429 }
430
431 fn performance_snapshot(&self) -> PerformanceMetricsSnapshot {
432 let positions = self.tracked_positions();
433 self.performance_tracker
434 .snapshot(self.engine.get_fills(), &positions)
435 }
436
437 pub async fn run(&mut self) {
439 tracing::info!("Starting engine runner...");
440
441 for (instance, exchange) in &self.exchanges {
443 if let Err(e) = exchange.init().await {
444 tracing::error!("Exchange {} init failed: {}", instance, e);
445 self.shutdown_reason = Some(format!("exchange_init_failed:{}", e));
446
447 let stop_cmds = self.engine.stop_strategies();
449 self.execute_commands(stop_cmds).await;
450 return;
451 }
452 }
453
454 let start_cmds = self.engine.start_strategies();
456 self.execute_commands(start_cmds).await;
457
458 let mut shutdown_rx = self.shutdown_rx.take().expect("run called twice");
460
461 const MAX_EMPTY_POLLS: u32 = 3;
463
464 #[cfg(target_arch = "wasm32")]
465 let mut loop_iteration: u32 = 0;
466
467 loop {
468 #[cfg(target_arch = "wasm32")]
469 {
470 loop_iteration += 1;
471 }
472
473 #[cfg(target_arch = "wasm32")]
475 if loop_iteration <= 500 || loop_iteration % 10 == 0 {
476 web_sys::console::log_1(
477 &format!("[WASM Runner] Loop iteration {}", loop_iteration).into(),
478 );
479 }
480 if self.should_shutdown {
482 tracing::info!("Strategy requested shutdown");
483 break;
484 }
485
486 match shutdown_rx.try_next() {
488 Ok(Some(_)) | Ok(None) => {
489 tracing::info!("Shutdown signal received");
490 break;
491 }
492 Err(_) => {} }
494
495 self.process_deferred_action_limit_commands().await;
496
497 let exchanges_snapshot: Vec<(ExchangeInstance, Arc<dyn Exchange>)> = self
499 .exchanges
500 .iter()
501 .map(|(i, e)| (i.clone(), e.clone()))
502 .collect();
503
504 let sync_mechanism = self.engine.sync_mechanism();
506
507 for (instance, exchange) in &exchanges_snapshot {
509 match sync_mechanism {
510 bot_core::SyncMechanism::Poll => {
511 match self
513 .fills_guard
514 .execute(|| exchange.poll_user_fills(None))
515 .await
516 {
517 PollOutcome::Data(fills) => {
518 self.engine
520 .set_exchange_health(instance, ExchangeHealth::Active);
521
522 for fill in fills {
524 let client_id = self.resolve_fill_client_id(&fill);
527 if client_id.is_none() {
528 #[cfg(feature = "native")]
531 if let Some(ref mut syncer) = self.trade_syncer {
532 syncer.add_fill(fill.clone());
533 }
534 continue;
535 }
536
537 let client_id = client_id.expect("checked above");
539 if !self
540 .apply_fill_and_emit_events(instance, &client_id, &fill)
541 .await
542 {
543 continue;
544 }
545
546 #[cfg(feature = "native")]
548 if let Some(ref mut syncer) = self.trade_syncer {
549 syncer.add_fill(fill.clone());
550 }
551
552 tracing::info!(
553 "Fill: {} {} {} @ {} (oid={:?} tid={})",
554 fill.side,
555 fill.qty,
556 fill.instrument,
557 fill.price,
558 fill.exchange_order_id,
559 fill.trade_id
560 );
561
562 self.stats.orders_filled += 1;
564 self.stats.volume_traded += fill.qty.0 * fill.price.0;
565 self.stats.total_fees += fill.fee.amount;
566 }
567 }
568 PollOutcome::Empty => {} PollOutcome::Degraded(_) | PollOutcome::Fatal(_) => {
570 self.engine
571 .set_exchange_health(instance, ExchangeHealth::Halted);
572 }
573 }
574 }
575 bot_core::SyncMechanism::Snapshot => {
576 match self
580 .fills_guard
581 .execute(|| async {
582 exchange.poll_account_state().await.map(|state| vec![state])
583 })
584 .await
585 {
586 PollOutcome::Data(mut states) => {
587 let account_state = states.remove(0);
588
589 self.engine
591 .set_exchange_health(instance, ExchangeHealth::Active);
592
593 for pos_snapshot in &account_state.positions {
595 self.engine.apply_snapshot(
596 &pos_snapshot.instrument,
597 pos_snapshot.qty,
598 pos_snapshot.avg_entry_px,
599 pos_snapshot.unrealized_pnl,
600 );
601
602 tracing::debug!(
603 "Position snapshot: {} qty={} entry={:?} pnl={:?}",
604 pos_snapshot.instrument,
605 pos_snapshot.qty,
606 pos_snapshot.avg_entry_px,
607 pos_snapshot.unrealized_pnl
608 );
609 }
610
611 #[cfg(feature = "native")]
613 let account_metrics_snapshot = if self.account_syncer.is_some() {
614 Some(self.performance_snapshot())
615 } else {
616 None
617 };
618 #[cfg(feature = "native")]
619 if let Some(ref mut syncer) = self.account_syncer {
620 syncer.set_metrics_snapshot(account_metrics_snapshot);
621 if syncer.should_sync() {
622 if let Err(e) = syncer.sync(&account_state, false, "").await
623 {
624 tracing::error!(
625 "[{}] Account sync failed: {}",
626 instance,
627 e
628 );
629 }
630 }
631 }
632
633 tracing::info!(
634 "Account snapshot: positions={} account_value={:?} pnl={:?}",
635 account_state.positions.len(),
636 account_state.account_value,
637 account_state.unrealized_pnl
638 );
639 }
640 PollOutcome::Empty => {} PollOutcome::Degraded(_) | PollOutcome::Fatal(_) => {
642 self.engine
643 .set_exchange_health(instance, ExchangeHealth::Halted);
644 }
645 }
646 }
647 }
648 }
649
650 if !self.instruments.is_empty() {
652 for (instance, exchange) in &exchanges_snapshot {
653 match self
654 .quotes_guard
655 .execute(|| exchange.poll_quotes(&self.instruments))
656 .await
657 {
658 PollOutcome::Data(quotes) => {
659 self.engine
660 .set_exchange_health(instance, ExchangeHealth::Active);
661
662 for quote in quotes {
663 self.engine.update_quote(quote.clone());
665
666 let mid = (quote.bid.0 + quote.ask.0) / Decimal::TWO;
668 self.current_mid_price = Some(mid);
669 tracing::debug!(
670 "[EngineRunner] Updated mid price: {} (bid={}, ask={})",
671 mid,
672 quote.bid,
673 quote.ask
674 );
675
676 let event = Event::Quote(QuoteEvent {
678 exchange: instance.exchange_id.clone(),
679 instrument: quote.instrument.clone(),
680 bid: quote.bid,
681 ask: quote.ask,
682 ts: quote.ts,
683 });
684
685 self.handle_event(event).await;
686
687 self.performance_tracker.record_equity_point(
688 quote.ts,
689 Some(mid),
690 self.current_net_pnl(),
691 );
692 }
693 }
694 PollOutcome::Empty => {} PollOutcome::Degraded(_) | PollOutcome::Fatal(_) => {
696 self.engine
697 .set_exchange_health(instance, ExchangeHealth::Halted);
698 }
699 }
700 }
701 }
702
703 if self.config.min_poll_delay_ms == 0
706 && self.quotes_guard.looks_exhausted(MAX_EMPTY_POLLS)
707 {
708 tracing::info!("[EngineRunner] Backtest complete: no more quotes available");
709
710 #[cfg(target_arch = "wasm32")]
711 web_sys::console::log_1(&"[WASM Runner] Backtest complete - exiting".into());
712
713 self.shutdown_reason = Some("Backtest complete - all quotes processed".to_string());
714 break;
715 }
716
717 #[cfg(feature = "native")]
719 let trade_metrics_snapshot = if self.trade_syncer.is_some() {
720 Some(self.performance_snapshot())
721 } else {
722 None
723 };
724 #[cfg(feature = "native")]
725 if let Some(ref mut syncer) = self.trade_syncer {
726 syncer.set_metrics_snapshot(trade_metrics_snapshot);
727 if syncer.should_sync() {
728 match syncer.sync(self.current_mid_price, false, "").await {
729 Ok(result) => {
730 if let Some(pnl) = result.pnl {
731 tracing::debug!("[EngineRunner] Sync success: pnl={:.4}", pnl);
732 }
733 }
734 Err(e) => {
735 tracing::warn!("[EngineRunner] Sync failed: {}", e);
736 }
737 }
738 }
739 }
740
741 let now_ms = bot_core::now_ms();
743 if now_ms - self.stats.last_meta_log_ms >= 30_000 {
744 self.stats.last_meta_log_ms = now_ms;
745
746 let positions: Vec<(InstrumentId, Position)> = self
747 .instruments
748 .iter()
749 .map(|i| (i.clone(), self.engine.position(i)))
750 .collect();
751
752 let pos_str = positions
753 .iter()
754 .map(|(instrument, position)| format!("{}:{:.4}", instrument, position.qty))
755 .collect::<Vec<_>>()
756 .join("/");
757
758 let pos_detail_str = positions
759 .iter()
760 .map(|(instrument, position)| {
761 let avg = position
762 .avg_entry_px
763 .map(|price| price.0.to_string())
764 .unwrap_or_else(|| "-".to_string());
765 let unrealized = position.unrealized_pnl.unwrap_or_default();
766 format!(
767 "{}[qty={:.4},avg={},r_pnl={:.4},u_pnl={:.4},net={:.4}]",
768 instrument,
769 position.qty,
770 avg,
771 position.realized_pnl,
772 unrealized,
773 position.current_pnl()
774 )
775 })
776 .collect::<Vec<_>>()
777 .join(" ");
778
779 let total_realized_pnl: Decimal =
780 positions.iter().map(|(_, p)| p.realized_pnl).sum();
781 let total_unrealized_pnl: Decimal = positions
782 .iter()
783 .map(|(_, p)| p.unrealized_pnl.unwrap_or_default())
784 .sum();
785 let total_position_fees: Decimal =
786 positions.iter().map(|(_, p)| p.total_fees).sum();
787 let total_net_pnl: Decimal = positions.iter().map(|(_, p)| p.current_pnl()).sum();
788
789 let sync_mechanism = self.engine.sync_mechanism();
790 if sync_mechanism == bot_core::SyncMechanism::Poll {
791 tracing::info!(
793 "[META] pos={} orders_placed={} orders_filled={} vol={:.2} fees={:.4} r_pnl={:.4} u_pnl={:.4} net_pnl={:.4} legs={}",
794 pos_str,
795 self.stats.orders_placed,
796 self.stats.orders_filled,
797 self.stats.volume_traded,
798 self.stats.total_fees,
799 total_realized_pnl,
800 total_unrealized_pnl,
801 total_net_pnl,
802 pos_detail_str
803 );
804 } else {
805 tracing::info!(
807 "[META] pos={} r_pnl={:.4} u_pnl={:.4} fees={:.4} net_pnl={:.4} legs={} (snapshot)",
808 pos_str,
809 total_realized_pnl,
810 total_unrealized_pnl,
811 total_position_fees,
812 total_net_pnl,
813 pos_detail_str
814 );
815 }
816 }
817
818 if self.config.min_poll_delay_ms > 0 {
820 sleep(Duration::from_millis(self.config.min_poll_delay_ms)).await;
821 } else {
822 crate::compat::yield_now().await;
824 }
825 }
826
827 let stop_cmds = self.engine.stop_strategies();
829 self.execute_commands(stop_cmds).await;
830
831 if self.config.cleanup_delay_ms > 0 {
833 tracing::info!(
834 "Waiting {}ms for cleanup to complete...",
835 self.config.cleanup_delay_ms
836 );
837 sleep(Duration::from_millis(self.config.cleanup_delay_ms)).await;
838 }
839
840 #[cfg(feature = "native")]
842 let final_trade_metrics_snapshot = if self.trade_syncer.is_some() {
843 Some(self.performance_snapshot())
844 } else {
845 None
846 };
847 #[cfg(feature = "native")]
848 if let Some(ref mut syncer) = self.trade_syncer {
849 syncer.set_metrics_snapshot(final_trade_metrics_snapshot);
850 let reason = self
851 .shutdown_reason
852 .as_deref()
853 .unwrap_or("shutdown:graceful");
854 tracing::info!(
855 "[EngineRunner] Performing final sync before shutdown with reason: {}",
856 reason
857 );
858 match syncer.shutdown_sync(self.current_mid_price, reason).await {
859 Ok(result) => {
860 tracing::info!("[EngineRunner] Final sync complete: pnl={:?}", result.pnl);
861 }
862 Err(e) => {
863 tracing::warn!("[EngineRunner] Final sync failed: {}", e);
864 }
865 }
866 }
867
868 if let Some(first_instrument) = self.instruments.first() {
871 let results = self.get_backtest_results(first_instrument);
872 if let Ok(json) = serde_json::to_string(&results) {
874 println!("{}", json);
875 use std::io::Write;
878 let _ = std::io::stdout().flush();
879 }
880 }
881
882 tracing::info!("Engine runner stopped");
883 }
884
885 async fn handle_event(&mut self, event: Event) {
886 use std::collections::VecDeque;
887
888 let mut queue: VecDeque<Event> = VecDeque::new();
889 queue.push_back(event);
890
891 while let Some(ev) = queue.pop_front() {
892 let cmds = self.engine.dispatch_event(&ev);
893 let followups = self.execute_commands(cmds).await;
894 for f in followups {
895 queue.push_back(f);
896 }
897 }
898 }
899
900 fn action_limit_retry_after(error: &ExchangeError) -> Option<u64> {
901 match error {
902 ExchangeError::WouldExceedUserActionLimit { retry_after_ms, .. } => {
903 Some(*retry_after_ms)
904 }
905 _ => None,
906 }
907 }
908
909 fn is_cancel_command(command: &Command) -> bool {
910 matches!(command, Command::CancelOrder(_) | Command::CancelAll(_))
911 }
912
913 fn same_cancel_command(left: &Command, right: &Command) -> bool {
914 match (left, right) {
915 (Command::CancelOrder(a), Command::CancelOrder(b)) => {
916 a.exchange == b.exchange && a.client_id == b.client_id
917 }
918 (Command::CancelAll(a), Command::CancelAll(b)) => {
919 a.exchange == b.exchange && a.instrument == b.instrument
920 }
921 _ => false,
922 }
923 }
924
925 fn defer_action_limit_command(&mut self, command: Command, retry_after_ms: u64) {
926 let retry_at_ms = bot_core::now_ms() + retry_after_ms as i64;
927
928 if Self::is_cancel_command(&command) {
929 if let Some(existing) = self
930 .deferred_action_limit_commands
931 .iter_mut()
932 .find(|existing| Self::same_cancel_command(&existing.command, &command))
933 {
934 existing.retry_at_ms = existing.retry_at_ms.min(retry_at_ms);
935 tracing::debug!(
936 "Coalesced duplicate Hyperliquid action-limit cancel command: {:?}",
937 command
938 );
939 return;
940 }
941 }
942
943 if self.deferred_action_limit_commands.len() >= MAX_DEFERRED_ACTION_LIMIT_COMMANDS {
944 tracing::error!(
945 "Action-limit deferred queue full ({} commands); stopping runner",
946 self.deferred_action_limit_commands.len()
947 );
948 self.shutdown_reason = Some("action_limit_deferred_queue_full".to_string());
949 self.should_shutdown = true;
950 return;
951 }
952
953 tracing::warn!(
954 "Deferring command for Hyperliquid action limit: retry_after_ms={} command={:?}",
955 retry_after_ms,
956 command
957 );
958 self.deferred_action_limit_commands
959 .push_back(DeferredActionLimitCommand {
960 retry_at_ms,
961 command,
962 });
963 }
964
965 fn next_deferred_action_limit_index(&self, now: i64) -> Option<usize> {
966 let mut selected: Option<(usize, bool, i64)> = None;
967
968 for (index, deferred) in self.deferred_action_limit_commands.iter().enumerate() {
969 if deferred.retry_at_ms > now {
970 continue;
971 }
972
973 let is_cancel = Self::is_cancel_command(&deferred.command);
974 match selected {
975 None => selected = Some((index, is_cancel, deferred.retry_at_ms)),
976 Some((_, selected_is_cancel, selected_retry_at_ms))
977 if (is_cancel && !selected_is_cancel)
978 || (is_cancel == selected_is_cancel
979 && deferred.retry_at_ms < selected_retry_at_ms) =>
980 {
981 selected = Some((index, is_cancel, deferred.retry_at_ms));
982 }
983 _ => {}
984 }
985 }
986
987 selected.map(|(index, _, _)| index)
988 }
989
990 async fn process_deferred_action_limit_commands(&mut self) {
991 let now = bot_core::now_ms();
992 let mut processed = 0usize;
993
994 while processed < MAX_DEFERRED_ACTION_LIMIT_PER_LOOP {
995 let Some(index) = self.next_deferred_action_limit_index(now) else {
996 break;
997 };
998
999 let deferred = self
1000 .deferred_action_limit_commands
1001 .remove(index)
1002 .expect("index selected from queue");
1003 tracing::info!(
1004 "Retrying Hyperliquid action-limit deferred command: {:?}",
1005 deferred.command
1006 );
1007 let followups = self.execute_commands(vec![deferred.command]).await;
1008 for event in followups {
1009 self.handle_event(event).await;
1010 }
1011 processed += 1;
1012 }
1013 }
1014
1015 fn resolve_fill_client_id(&self, fill: &Fill) -> Option<ClientOrderId> {
1016 if let Some(cid) = fill.client_id.clone() {
1017 return Some(cid);
1018 }
1019
1020 if let Some(ref eid) = fill.exchange_order_id {
1021 if let Some(cid) = self.engine.order_manager().client_id_from_exchange_id(eid) {
1022 return Some(cid.clone());
1023 }
1024 }
1025
1026 None
1027 }
1028
1029 async fn apply_fill_and_emit_events(
1030 &mut self,
1031 instance: &ExchangeInstance,
1032 client_id: &ClientOrderId,
1033 fill: &Fill,
1034 ) -> bool {
1035 let is_new = self.engine.order_manager_mut().apply_fill(
1037 client_id,
1038 &fill.trade_id,
1039 fill.qty,
1040 fill.price,
1041 );
1042
1043 if !is_new {
1044 return false;
1045 }
1046
1047 let net_qty = if let Some(meta) = self.engine.instrument_meta(&fill.instrument) {
1050 if meta.kind == bot_core::InstrumentKind::Spot
1051 && fill.side == bot_core::OrderSide::Buy
1052 && fill.fee.asset == meta.base_asset
1053 {
1054 let nq = Qty::new((fill.qty.0 - fill.fee.amount).max(Decimal::ZERO));
1056 tracing::debug!(
1057 "Spot BUY fee deduction: gross={} fee={} net={}",
1058 fill.qty,
1059 fill.fee.amount,
1060 nq
1061 );
1062 nq
1063 } else {
1064 fill.qty
1065 }
1066 } else {
1067 fill.qty
1068 };
1069
1070 self.engine
1072 .apply_position_fill(&fill.instrument, fill.side, net_qty, fill.price);
1073
1074 self.engine
1076 .apply_fill_fee(&fill.instrument, fill.fee.amount);
1077
1078 let filled_event = Event::OrderFilled(OrderFilledEvent {
1080 exchange: instance.exchange_id.clone(),
1081 instrument: fill.instrument.clone(),
1082 client_id: client_id.clone(),
1083 trade_id: fill.trade_id.clone(),
1084 side: fill.side,
1085 price: fill.price,
1086 qty: fill.qty,
1087 net_qty,
1088 fee: fill.fee.clone(),
1089 ts: fill.ts,
1090 });
1091
1092 if let Event::OrderFilled(ref fill_event) = filled_event {
1094 self.engine.record_fill(fill_event.clone());
1095 }
1096
1097 self.handle_event(filled_event).await;
1098
1099 let is_complete = self.engine.order_manager().is_complete(client_id);
1101 if is_complete {
1102 if let Some(order) = self.engine.order_manager().get(client_id).cloned() {
1103 let completed_event = Event::OrderCompleted(OrderCompletedEvent {
1104 exchange: instance.exchange_id.clone(),
1105 instrument: order.instrument.clone(),
1106 client_id: client_id.clone(),
1107 filled_qty: order.filled_qty,
1108 avg_fill_px: order.avg_fill_px,
1109 ts: bot_core::now_ms(),
1110 });
1111 self.handle_event(completed_event).await;
1112 }
1113 }
1114
1115 true
1116 }
1117
1118 async fn execute_commands(&mut self, cmds: Vec<Command>) -> Vec<Event> {
1119 let mut followups: Vec<Event> = Vec::new();
1120
1121 for cmd in cmds {
1122 match cmd {
1123 Command::PlaceOrder(c) => {
1124 let mut evs = self.execute_place(c).await;
1125 followups.append(&mut evs);
1126 }
1127 Command::PlaceOrders(orders) => {
1128 let mut evs = self.execute_place_batch(orders).await;
1129 followups.append(&mut evs);
1130 }
1131 Command::CancelOrder(c) => {
1132 if let Some(ev) = self.execute_cancel(c).await {
1133 followups.push(ev);
1134 }
1135 }
1136 Command::CancelAll(c) => {
1137 let mut evs = self.execute_cancel_all(c).await;
1138 followups.append(&mut evs);
1139 }
1140 Command::StopStrategy(stop) => {
1141 tracing::info!("Strategy {} stopped: {}", stop.strategy_id.0, stop.reason);
1143 self.shutdown_reason = Some(stop.reason.clone());
1145 self.should_shutdown = true;
1147 }
1148 }
1149 }
1150
1151 followups
1152 }
1153
1154 async fn execute_place(&mut self, cmd: PlaceOrder) -> Vec<Event> {
1155 #[cfg(feature = "wasm")]
1156 web_sys::console::log_1(
1157 &format!(
1158 "[Runner] execute_place called: instrument={}, exchange={:?}",
1159 cmd.instrument, cmd.exchange
1160 )
1161 .into(),
1162 );
1163
1164 let Some(exchange) = self.exchanges.get(&cmd.exchange).cloned() else {
1165 #[cfg(feature = "wasm")]
1166 {
1167 let available: Vec<_> = self.exchanges.keys().collect();
1168 web_sys::console::log_1(
1169 &format!(
1170 "[Runner] Exchange NOT FOUND: {:?}, available: {:?}",
1171 cmd.exchange, available
1172 )
1173 .into(),
1174 );
1175 }
1176 return vec![];
1177 };
1178
1179 #[cfg(feature = "wasm")]
1180 web_sys::console::log_1(&format!("[Runner] Exchange found, proceeding with order").into());
1181
1182 let (market_index, rounded_price, rounded_qty) =
1183 match self.engine.instrument_meta(&cmd.instrument) {
1184 Some(meta) => {
1185 let rp = meta.round_price(cmd.price);
1186 let rq = meta.round_qty(cmd.qty);
1187 tracing::debug!(
1188 "Rounding: price {} -> {}, qty {} -> {}",
1189 cmd.price,
1190 rp,
1191 cmd.qty,
1192 rq
1193 );
1194 (meta.market_index.clone(), rp, rq)
1195 }
1196 None => {
1197 tracing::warn!(
1198 "PlaceOrder ignored: no InstrumentMeta for {}",
1199 cmd.instrument
1200 );
1201 return vec![Event::OrderRejected(OrderRejectedEvent {
1202 exchange: cmd.exchange.exchange_id.clone(),
1203 instrument: cmd.instrument.clone(),
1204 client_id: cmd.client_id.clone(),
1205 reason: "missing instrument meta".to_string(),
1206 ts: bot_core::now_ms(),
1207 })];
1208 }
1209 };
1210
1211 self.engine.order_manager_mut().create_order(
1213 cmd.client_id.clone(),
1214 cmd.instrument.clone(),
1215 cmd.side,
1216 rounded_price,
1217 rounded_qty,
1218 );
1219
1220 let order_input = OrderInput {
1222 instrument: cmd.instrument.clone(),
1223 market_index,
1224 client_id: cmd.client_id.clone(),
1225 side: cmd.side,
1226 price: rounded_price,
1227 qty: rounded_qty,
1228 tif: cmd.tif,
1229 post_only: cmd.post_only,
1230 reduce_only: cmd.reduce_only,
1231 };
1232
1233 match exchange.place_orders(&[order_input]).await {
1234 Ok(results) => {
1235 match results.into_iter().next() {
1237 Some(PlaceOrderResult::Accepted {
1238 exchange_order_id,
1239 filled_qty,
1240 avg_fill_px,
1241 }) => {
1242 self.engine
1243 .order_manager_mut()
1244 .accept_order(&cmd.client_id, exchange_order_id.clone());
1245
1246 self.stats.orders_placed += 1;
1248
1249 let accepted_event = Event::OrderAccepted(OrderAcceptedEvent {
1251 exchange: cmd.exchange.exchange_id.clone(),
1252 instrument: cmd.instrument.clone(),
1253 client_id: cmd.client_id.clone(),
1254 exchange_order_id,
1255 ts: bot_core::now_ms(),
1256 });
1257
1258 let use_ioc_fill =
1261 self.engine.sync_mechanism() == bot_core::SyncMechanism::Snapshot;
1262
1263 if use_ioc_fill {
1264 if let (Some(qty), Some(px)) = (&filled_qty, &avg_fill_px) {
1265 vec![
1267 accepted_event,
1268 Event::OrderCompleted(OrderCompletedEvent {
1269 exchange: cmd.exchange.exchange_id.clone(),
1270 instrument: cmd.instrument.clone(),
1271 client_id: cmd.client_id.clone(),
1272 filled_qty: *qty,
1273 avg_fill_px: Some(*px),
1274 ts: bot_core::now_ms(),
1275 }),
1276 ]
1277 } else {
1278 vec![accepted_event]
1279 }
1280 } else {
1281 vec![accepted_event]
1282 }
1283 }
1284 Some(PlaceOrderResult::Rejected { reason }) => {
1285 self.engine.order_manager_mut().reject_order(&cmd.client_id);
1286 vec![Event::OrderRejected(OrderRejectedEvent {
1287 exchange: cmd.exchange.exchange_id.clone(),
1288 instrument: cmd.instrument.clone(),
1289 client_id: cmd.client_id.clone(),
1290 reason,
1291 ts: bot_core::now_ms(),
1292 })]
1293 }
1294 None => {
1295 self.engine.order_manager_mut().reject_order(&cmd.client_id);
1296 vec![Event::OrderRejected(OrderRejectedEvent {
1297 exchange: cmd.exchange.exchange_id.clone(),
1298 instrument: cmd.instrument.clone(),
1299 client_id: cmd.client_id.clone(),
1300 reason: "No result returned from place_orders".to_string(),
1301 ts: bot_core::now_ms(),
1302 })]
1303 }
1304 }
1305 }
1306 Err(e) => {
1307 if let Some(retry_after_ms) = Self::action_limit_retry_after(&e) {
1308 self.defer_action_limit_command(Command::PlaceOrder(cmd), retry_after_ms);
1309 return vec![];
1310 }
1311
1312 self.engine.order_manager_mut().reject_order(&cmd.client_id);
1313 vec![Event::OrderRejected(OrderRejectedEvent {
1314 exchange: cmd.exchange.exchange_id.clone(),
1315 instrument: cmd.instrument.clone(),
1316 client_id: cmd.client_id.clone(),
1317 reason: e.to_string(),
1318 ts: bot_core::now_ms(),
1319 })]
1320 }
1321 }
1322 }
1323
1324 async fn execute_place_batch(&mut self, orders: Vec<PlaceOrder>) -> Vec<Event> {
1326 if orders.is_empty() {
1327 return Vec::new();
1328 }
1329
1330 let exchange_instance = &orders[0].exchange;
1332 let exchange = match self.exchanges.get(exchange_instance) {
1333 Some(e) => e.clone(),
1334 None => {
1335 return orders
1336 .iter()
1337 .map(|cmd| {
1338 Event::OrderRejected(OrderRejectedEvent {
1339 exchange: cmd.exchange.exchange_id.clone(),
1340 instrument: cmd.instrument.clone(),
1341 client_id: cmd.client_id.clone(),
1342 reason: "Exchange not found".to_string(),
1343 ts: bot_core::now_ms(),
1344 })
1345 })
1346 .collect();
1347 }
1348 };
1349
1350 let mut order_inputs: Vec<OrderInput> = Vec::with_capacity(orders.len());
1352 let mut cmd_metadata: Vec<(PlaceOrder, bool)> = Vec::with_capacity(orders.len()); for cmd in orders {
1355 let meta_result = self.engine.instrument_meta(&cmd.instrument).map(|meta| {
1356 let rp = meta.round_price(cmd.price);
1357 let rq = meta.round_qty(cmd.qty);
1358 (meta.market_index.clone(), rp, rq)
1359 });
1360
1361 match meta_result {
1362 Some((market_index, rounded_price, rounded_qty)) => {
1363 self.engine.order_manager_mut().create_order(
1365 cmd.client_id.clone(),
1366 cmd.instrument.clone(),
1367 cmd.side,
1368 rounded_price,
1369 rounded_qty,
1370 );
1371
1372 order_inputs.push(OrderInput {
1373 instrument: cmd.instrument.clone(),
1374 market_index,
1375 client_id: cmd.client_id.clone(),
1376 side: cmd.side,
1377 price: rounded_price,
1378 qty: rounded_qty,
1379 tif: cmd.tif,
1380 post_only: cmd.post_only,
1381 reduce_only: cmd.reduce_only,
1382 });
1383 cmd_metadata.push((cmd, true));
1384 }
1385 None => {
1386 tracing::warn!(
1387 "PlaceOrder ignored: no InstrumentMeta for {}",
1388 cmd.instrument
1389 );
1390 cmd_metadata.push((cmd, false));
1391 }
1392 }
1393 }
1394
1395 let mut events: Vec<Event> = cmd_metadata
1397 .iter()
1398 .filter(|(_, valid)| !valid)
1399 .map(|(cmd, _)| {
1400 Event::OrderRejected(OrderRejectedEvent {
1401 exchange: cmd.exchange.exchange_id.clone(),
1402 instrument: cmd.instrument.clone(),
1403 client_id: cmd.client_id.clone(),
1404 reason: "missing instrument meta".to_string(),
1405 ts: bot_core::now_ms(),
1406 })
1407 })
1408 .collect();
1409
1410 if order_inputs.is_empty() {
1411 return events;
1412 }
1413
1414 tracing::info!(
1415 "Executing batch place_orders with {} orders",
1416 order_inputs.len()
1417 );
1418
1419 match exchange.place_orders(&order_inputs).await {
1421 Ok(results) => {
1422 let valid_cmds: Vec<&PlaceOrder> = cmd_metadata
1424 .iter()
1425 .filter(|(_, valid)| *valid)
1426 .map(|(cmd, _)| cmd)
1427 .collect();
1428
1429 for (i, result) in results.into_iter().enumerate() {
1430 let cmd = valid_cmds.get(i);
1431 if let Some(cmd) = cmd {
1432 match result {
1433 PlaceOrderResult::Accepted {
1434 exchange_order_id,
1435 filled_qty,
1436 avg_fill_px,
1437 } => {
1438 self.engine
1439 .order_manager_mut()
1440 .accept_order(&cmd.client_id, exchange_order_id.clone());
1441
1442 let use_ioc_fill = self.engine.sync_mechanism()
1445 == bot_core::SyncMechanism::Snapshot;
1446
1447 if use_ioc_fill {
1448 if let (Some(qty), Some(px)) = (&filled_qty, &avg_fill_px) {
1449 events.push(Event::OrderCompleted(OrderCompletedEvent {
1450 exchange: cmd.exchange.exchange_id.clone(),
1451 instrument: cmd.instrument.clone(),
1452 client_id: cmd.client_id.clone(),
1453 filled_qty: *qty,
1454 avg_fill_px: Some(*px),
1455 ts: bot_core::now_ms(),
1456 }));
1457 } else {
1458 events.push(Event::OrderAccepted(OrderAcceptedEvent {
1459 exchange: cmd.exchange.exchange_id.clone(),
1460 instrument: cmd.instrument.clone(),
1461 client_id: cmd.client_id.clone(),
1462 exchange_order_id,
1463 ts: bot_core::now_ms(),
1464 }));
1465 }
1466 } else {
1467 events.push(Event::OrderAccepted(OrderAcceptedEvent {
1468 exchange: cmd.exchange.exchange_id.clone(),
1469 instrument: cmd.instrument.clone(),
1470 client_id: cmd.client_id.clone(),
1471 exchange_order_id,
1472 ts: bot_core::now_ms(),
1473 }));
1474 }
1475 }
1476 PlaceOrderResult::Rejected { reason } => {
1477 self.engine.order_manager_mut().reject_order(&cmd.client_id);
1478 events.push(Event::OrderRejected(OrderRejectedEvent {
1479 exchange: cmd.exchange.exchange_id.clone(),
1480 instrument: cmd.instrument.clone(),
1481 client_id: cmd.client_id.clone(),
1482 reason,
1483 ts: bot_core::now_ms(),
1484 }));
1485 }
1486 }
1487 }
1488 }
1489 }
1490 Err(e) => {
1491 if let Some(retry_after_ms) = Self::action_limit_retry_after(&e) {
1492 let deferred_orders: Vec<PlaceOrder> = cmd_metadata
1493 .iter()
1494 .filter(|(_, valid)| *valid)
1495 .map(|(cmd, _)| cmd.clone())
1496 .collect();
1497
1498 if !deferred_orders.is_empty() {
1499 self.defer_action_limit_command(
1500 Command::PlaceOrders(deferred_orders),
1501 retry_after_ms,
1502 );
1503 }
1504
1505 return events;
1506 }
1507
1508 for (cmd, valid) in &cmd_metadata {
1510 if *valid {
1511 self.engine.order_manager_mut().reject_order(&cmd.client_id);
1512 events.push(Event::OrderRejected(OrderRejectedEvent {
1513 exchange: cmd.exchange.exchange_id.clone(),
1514 instrument: cmd.instrument.clone(),
1515 client_id: cmd.client_id.clone(),
1516 reason: e.to_string(),
1517 ts: bot_core::now_ms(),
1518 }));
1519 }
1520 }
1521 }
1522 }
1523
1524 events
1525 }
1526
1527 async fn execute_cancel(&mut self, cmd: CancelOrder) -> Option<Event> {
1528 let exchange = self.exchanges.get(&cmd.exchange)?.clone();
1529
1530 let (instrument, exchange_order_id) = match self.engine.order_manager().get(&cmd.client_id)
1531 {
1532 Some(o) => (o.instrument.clone(), o.exchange_order_id.clone()),
1533 None => {
1534 tracing::warn!("CancelOrder ignored: unknown client_id {}", cmd.client_id);
1535 return None;
1536 }
1537 };
1538
1539 let market_index = match self.engine.instrument_meta(&instrument) {
1540 Some(m) => m.market_index.clone(),
1541 None => {
1542 tracing::warn!("CancelOrder ignored: no InstrumentMeta for {}", instrument);
1543 return None;
1544 }
1545 };
1546
1547 match exchange
1548 .cancel_order(
1549 &instrument,
1550 &market_index,
1551 &cmd.client_id,
1552 exchange_order_id.as_ref(),
1553 )
1554 .await
1555 {
1556 Ok(_) => {
1557 self.engine.order_manager_mut().cancel_order(&cmd.client_id);
1558 Some(Event::OrderCanceled(OrderCanceledEvent {
1559 exchange: cmd.exchange.exchange_id.clone(),
1560 instrument,
1561 client_id: cmd.client_id.clone(),
1562 reason: None,
1563 ts: bot_core::now_ms(),
1564 }))
1565 }
1566 Err(e) => {
1567 if let Some(retry_after_ms) = Self::action_limit_retry_after(&e) {
1568 self.defer_action_limit_command(Command::CancelOrder(cmd), retry_after_ms);
1569 return None;
1570 }
1571
1572 tracing::warn!("CancelOrder failed for {}: {}", cmd.client_id, e);
1573 None
1574 }
1575 }
1576 }
1577
1578 async fn execute_cancel_all(&mut self, cmd: CancelAll) -> Vec<Event> {
1579 tracing::info!(
1580 "Executing CancelAll for exchange={:?} instrument={:?}",
1581 cmd.exchange.exchange_id,
1582 cmd.instrument.as_ref().map(|i| i.to_string())
1583 );
1584 let out = Vec::new();
1585 let exchange = match self.exchanges.get(&cmd.exchange) {
1586 Some(e) => e.clone(),
1587 None => {
1588 tracing::warn!(
1589 "CancelAll: exchange not found {:?}",
1590 cmd.exchange.exchange_id
1591 );
1592 return out;
1593 }
1594 };
1595
1596 let instruments: Vec<InstrumentId> = if let Some(i) = cmd.instrument.clone() {
1597 vec![i]
1598 } else {
1599 self.instruments.clone()
1600 };
1601
1602 for instrument in instruments {
1603 let market_index = match self.engine.instrument_meta(&instrument) {
1604 Some(m) => m.market_index.clone(),
1605 None => continue,
1606 };
1607
1608 tracing::info!("CancelAll: calling cancel_all_orders for {}", instrument);
1609 match exchange.cancel_all_orders(&instrument, &market_index).await {
1610 Ok(n) => {
1611 tracing::info!(
1612 "CancelAll: successfully canceled {} orders for {}",
1613 n,
1614 instrument
1615 );
1616 }
1617 Err(e) => {
1618 if let Some(retry_after_ms) = Self::action_limit_retry_after(&e) {
1619 self.defer_action_limit_command(
1620 Command::CancelAll(cmd.clone()),
1621 retry_after_ms,
1622 );
1623 return out;
1624 }
1625
1626 tracing::warn!("CancelAll failed for {}: {}", instrument, e);
1627 }
1628 }
1629 }
1630
1631 out
1632 }
1633}
1634
1635#[cfg(feature = "native")]
1637pub fn spawn_runner(
1638 engine: Engine,
1639 exchanges: Vec<Arc<dyn Exchange>>,
1640 instruments: Vec<InstrumentId>,
1641 config: RunnerConfig,
1642) -> (tokio::task::JoinHandle<()>, mpsc::UnboundedSender<()>) {
1643 spawn_runner_with_syncer(engine, exchanges, instruments, config, None, None)
1644}
1645
1646#[cfg(feature = "native")]
1648pub fn spawn_runner_with_syncer(
1649 engine: Engine,
1650 exchanges: Vec<Arc<dyn Exchange>>,
1651 instruments: Vec<InstrumentId>,
1652 config: RunnerConfig,
1653 syncer_config: Option<TradeSyncerConfig>,
1654 account_syncer_config: Option<AccountSyncerConfig>,
1655) -> (tokio::task::JoinHandle<()>, mpsc::UnboundedSender<()>) {
1656 let mut runner = EngineRunner::new(engine, config);
1657
1658 if let Some(syncer_cfg) = syncer_config {
1660 runner = runner.with_syncer(syncer_cfg);
1661 }
1662
1663 if let Some(account_cfg) = account_syncer_config {
1665 runner = runner.with_account_syncer(account_cfg);
1666 }
1667
1668 for exchange in exchanges {
1669 runner.add_exchange(exchange);
1670 }
1671 for instrument in instruments {
1672 runner.add_instrument(instrument);
1673 }
1674
1675 let shutdown_handle = runner.shutdown_handle();
1676
1677 let handle = tokio::spawn(async move {
1678 runner.run().await;
1679 });
1680
1681 (handle, shutdown_handle)
1682}
1683
1684#[cfg(test)]
1685mod tests {
1686 use super::*;
1687 use crate::testing::MockExchange;
1688 use crate::EngineConfig;
1689 use bot_core::{
1690 AssetId, Environment, ExchangeId, Fee, InstrumentKind, InstrumentMeta, MarketIndex,
1691 OrderSide, Price, Quote, Strategy, StrategyContext, StrategyId, TimeInForce, TradeId,
1692 };
1693 use rust_decimal::Decimal;
1694 use rust_decimal_macros::dec;
1695 use std::sync::atomic::{AtomicUsize, Ordering};
1696 use tokio::time::timeout;
1697
1698 struct DeferredPlacementProbeStrategy {
1699 id: StrategyId,
1700 exchange: ExchangeInstance,
1701 instrument: InstrumentId,
1702 accepted: Arc<AtomicUsize>,
1703 rejected: Arc<AtomicUsize>,
1704 single_sent: bool,
1705 }
1706
1707 impl DeferredPlacementProbeStrategy {
1708 fn new(
1709 exchange: ExchangeInstance,
1710 instrument: InstrumentId,
1711 accepted: Arc<AtomicUsize>,
1712 rejected: Arc<AtomicUsize>,
1713 ) -> Self {
1714 Self {
1715 id: StrategyId::new("deferred-placement-probe"),
1716 exchange,
1717 instrument,
1718 accepted,
1719 rejected,
1720 single_sent: false,
1721 }
1722 }
1723
1724 fn order(&self, client_id: &str) -> PlaceOrder {
1725 PlaceOrder {
1726 client_id: ClientOrderId::new(client_id),
1727 exchange: self.exchange.clone(),
1728 instrument: self.instrument.clone(),
1729 side: OrderSide::Buy,
1730 price: Price::new(Decimal::new(100, 0)),
1731 qty: Qty::new(Decimal::new(1, 0)),
1732 tif: TimeInForce::Gtc,
1733 post_only: false,
1734 reduce_only: false,
1735 }
1736 }
1737 }
1738
1739 impl Strategy for DeferredPlacementProbeStrategy {
1740 fn id(&self) -> &StrategyId {
1741 &self.id
1742 }
1743
1744 fn on_start(&mut self, ctx: &mut dyn StrategyContext) {
1745 ctx.place_orders(vec![self.order("batch-1"), self.order("batch-2")]);
1746 }
1747
1748 fn on_event(&mut self, ctx: &mut dyn StrategyContext, event: &Event) {
1749 match event {
1750 Event::OrderAccepted(_) => {
1751 let accepted = self.accepted.fetch_add(1, Ordering::SeqCst) + 1;
1752 if accepted == 2 && !self.single_sent {
1753 self.single_sent = true;
1754 ctx.place_order(self.order("later-single"));
1755 }
1756 if accepted >= 3 {
1757 ctx.stop_strategy(self.id.clone(), "probe complete");
1758 }
1759 }
1760 Event::OrderRejected(_) => {
1761 self.rejected.fetch_add(1, Ordering::SeqCst);
1762 }
1763 _ => {}
1764 }
1765 }
1766
1767 fn on_timer(&mut self, _ctx: &mut dyn StrategyContext, _timer_id: bot_core::TimerId) {}
1768
1769 fn on_stop(&mut self, _ctx: &mut dyn StrategyContext) {}
1770 }
1771
1772 fn action_limit_error(needed: u32) -> ExchangeError {
1773 ExchangeError::WouldExceedUserActionLimit {
1774 retry_after_ms: 1,
1775 needed,
1776 }
1777 }
1778
1779 fn instrument_meta(instrument: &InstrumentId) -> InstrumentMeta {
1780 InstrumentMeta {
1781 instrument_id: instrument.clone(),
1782 market_index: MarketIndex::new(0),
1783 base_asset: AssetId::new("BTC"),
1784 quote_asset: AssetId::new("USDC"),
1785 tick_size: Decimal::new(1, 0),
1786 lot_size: Decimal::new(1, 0),
1787 min_qty: None,
1788 min_notional: None,
1789 fee_asset_default: Some(AssetId::new("USDC")),
1790 kind: InstrumentKind::Perp,
1791 }
1792 }
1793
1794 fn record_backtest_fill(
1795 runner: &mut EngineRunner,
1796 instrument: &InstrumentId,
1797 side: OrderSide,
1798 price: Decimal,
1799 qty: Decimal,
1800 fee: Decimal,
1801 ts: i64,
1802 trade_id: &str,
1803 ) {
1804 runner
1805 .engine
1806 .apply_position_fill(instrument, side, Qty::new(qty), Price::new(price));
1807 runner.engine.apply_fill_fee(instrument, fee);
1808 runner.engine.record_fill(OrderFilledEvent {
1809 exchange: ExchangeId::new("hyperliquid"),
1810 trade_id: TradeId::new(trade_id),
1811 client_id: ClientOrderId::new(format!("client-{}", trade_id)),
1812 instrument: instrument.clone(),
1813 side,
1814 price: Price::new(price),
1815 qty: Qty::new(qty),
1816 net_qty: Qty::new(qty),
1817 fee: Fee::new(fee, AssetId::new("USDC")),
1818 ts,
1819 });
1820 }
1821
1822 fn record_backtest_equity(
1823 runner: &mut EngineRunner,
1824 instrument: &InstrumentId,
1825 price: Decimal,
1826 ts: i64,
1827 ) {
1828 runner.engine.update_quote(Quote {
1829 instrument: instrument.clone(),
1830 bid: Price::new(price),
1831 ask: Price::new(price),
1832 bid_size: Qty::new(dec!(1000)),
1833 ask_size: Qty::new(dec!(1000)),
1834 ts,
1835 });
1836 runner
1837 .performance_tracker
1838 .record_equity_point(ts, Some(price), runner.current_net_pnl());
1839 }
1840
1841 #[test]
1842 fn backtest_result_embeds_deterministic_performance_metrics() {
1843 let instrument = InstrumentId::new("BTC-PERP");
1844 let mut engine = Engine::new(EngineConfig::default());
1845 engine.register_instrument(instrument_meta(&instrument));
1846
1847 let mut runner = EngineRunner::new(
1848 engine,
1849 RunnerConfig {
1850 metrics_mode: "backtest".to_string(),
1851 metrics_starting_balance_usdc: Some(dec!(1000)),
1852 cleanup_delay_ms: 0,
1853 ..Default::default()
1854 },
1855 );
1856 runner.add_instrument(instrument.clone());
1857
1858 record_backtest_equity(&mut runner, &instrument, dec!(100), 1);
1859 record_backtest_fill(
1860 &mut runner,
1861 &instrument,
1862 OrderSide::Buy,
1863 dec!(100),
1864 dec!(1),
1865 dec!(1),
1866 2,
1867 "open-long",
1868 );
1869 record_backtest_equity(&mut runner, &instrument, dec!(100), 2);
1870 record_backtest_fill(
1871 &mut runner,
1872 &instrument,
1873 OrderSide::Sell,
1874 dec!(110),
1875 dec!(1),
1876 dec!(1),
1877 3,
1878 "close-long",
1879 );
1880 record_backtest_equity(&mut runner, &instrument, dec!(110), 3);
1881
1882 let result = runner.get_backtest_results(&instrument);
1883
1884 assert_eq!(result.trade_count, 2);
1885 assert_eq!(result.metrics.fill_count, 2);
1886 assert_eq!(result.metrics.closed_trade_count, 1);
1887 assert_eq!(result.metrics.win_rate_pct, Some(100.0));
1888 assert_eq!(result.metrics.winning_trade_count, 1);
1889 assert_eq!(result.metrics.losing_trade_count, 0);
1890 assert_eq!(result.metrics.total_fees, "2");
1891 assert_eq!(result.metrics.total_volume, "210");
1892 assert_eq!(result.metrics.net_pnl, "8");
1893 assert_eq!(result.realized_pnl, "10");
1894 assert_eq!(result.net_pnl, "8");
1895 assert_eq!(result.positions.len(), 1);
1896 assert_eq!(result.positions[0].instrument, "BTC-PERP");
1897 assert_eq!(result.positions[0].net_pnl, "8");
1898 assert_eq!(result.metrics.period_return_pct, Some(0.8));
1899 assert_eq!(result.metrics.max_drawdown_usdc, "1");
1900 assert_eq!(result.metrics.max_drawdown_pct, Some(0.1));
1901 assert_eq!(result.fills[0].fee, "USDC");
1902 assert_eq!(result.fills[0].fee_amount, "1");
1903 assert_eq!(result.fills[0].fee_currency, "USDC");
1904 assert_eq!(result.closed_trades.len(), 1);
1905 assert_eq!(result.closed_trades[0].gross_pnl, "10");
1906 assert_eq!(result.closed_trades[0].fees, "2");
1907 assert_eq!(result.closed_trades[0].net_pnl, "8");
1908 assert_eq!(result.equity_curve.len(), 3);
1909 assert_eq!(result.benchmark.quote_count, 3);
1910 assert_eq!(
1911 result.benchmark.starting_balance_usdc,
1912 Some("1000".to_string())
1913 );
1914 assert_eq!(
1915 result.benchmark.ending_balance_usdc,
1916 Some("1008".to_string())
1917 );
1918 assert_eq!(result.benchmark.instrument, Some("BTC-PERP".to_string()));
1919 }
1920
1921 #[test]
1922 fn backtest_result_aggregates_multi_instrument_pnl() {
1923 let yes = InstrumentId::new("#50-OUTCOME");
1924 let no = InstrumentId::new("#51-OUTCOME");
1925 let mut engine = Engine::new(EngineConfig::default());
1926 engine.register_instrument(instrument_meta(&yes));
1927 engine.register_instrument(instrument_meta(&no));
1928
1929 let mut runner = EngineRunner::new(
1930 engine,
1931 RunnerConfig {
1932 metrics_mode: "backtest".to_string(),
1933 metrics_starting_balance_usdc: Some(dec!(100)),
1934 cleanup_delay_ms: 0,
1935 ..Default::default()
1936 },
1937 );
1938 runner.add_instrument(yes.clone());
1939 runner.add_instrument(no.clone());
1940
1941 record_backtest_fill(
1942 &mut runner,
1943 &yes,
1944 OrderSide::Buy,
1945 dec!(1),
1946 dec!(10),
1947 Decimal::ZERO,
1948 1,
1949 "yes-open",
1950 );
1951 record_backtest_fill(
1952 &mut runner,
1953 &yes,
1954 OrderSide::Sell,
1955 dec!(2),
1956 dec!(5),
1957 Decimal::ZERO,
1958 2,
1959 "yes-partial-close",
1960 );
1961 record_backtest_fill(
1962 &mut runner,
1963 &no,
1964 OrderSide::Buy,
1965 dec!(4),
1966 dec!(10),
1967 Decimal::ZERO,
1968 3,
1969 "no-open",
1970 );
1971 record_backtest_fill(
1972 &mut runner,
1973 &no,
1974 OrderSide::Sell,
1975 dec!(5),
1976 dec!(10),
1977 Decimal::ZERO,
1978 4,
1979 "no-close",
1980 );
1981 record_backtest_equity(&mut runner, &yes, dec!(3), 5);
1982 record_backtest_equity(&mut runner, &no, dec!(5), 5);
1983
1984 let result = runner.get_backtest_results(&yes);
1985
1986 assert_eq!(result.realized_pnl, "15");
1987 assert_eq!(result.unrealized_pnl, Some("10".to_string()));
1988 assert_eq!(result.net_pnl, "25");
1989 assert_eq!(result.positions.len(), 2);
1990
1991 let yes_position = result
1992 .positions
1993 .iter()
1994 .find(|position| position.instrument == "#50-OUTCOME")
1995 .expect("yes position summary");
1996 assert_eq!(yes_position.final_position_qty, "5");
1997 assert_eq!(yes_position.realized_pnl, "5");
1998 assert_eq!(yes_position.unrealized_pnl, Some("10".to_string()));
1999 assert_eq!(yes_position.net_pnl, "15");
2000
2001 let no_position = result
2002 .positions
2003 .iter()
2004 .find(|position| position.instrument == "#51-OUTCOME")
2005 .expect("no position summary");
2006 assert_eq!(no_position.final_position_qty, "0");
2007 assert_eq!(no_position.realized_pnl, "10");
2008 assert_eq!(no_position.unrealized_pnl, None);
2009 assert_eq!(no_position.net_pnl, "10");
2010 }
2011
2012 #[tokio::test]
2013 async fn action_limit_defers_batch_and_later_single_without_rejection() {
2014 let exchange_instance =
2015 ExchangeInstance::new(ExchangeId::new("hyperliquid"), Environment::Testnet);
2016 let instrument = InstrumentId::new("BTC-PERP");
2017 let mock = Arc::new(MockExchange::new());
2018
2019 mock.queue_place_order_error(action_limit_error(2)).await;
2020 mock.queue_place_order_success().await;
2021 mock.queue_place_order_error(action_limit_error(1)).await;
2022 mock.queue_place_order_success().await;
2023
2024 let accepted = Arc::new(AtomicUsize::new(0));
2025 let rejected = Arc::new(AtomicUsize::new(0));
2026 let strategy = Box::new(DeferredPlacementProbeStrategy::new(
2027 exchange_instance,
2028 instrument.clone(),
2029 accepted.clone(),
2030 rejected.clone(),
2031 ));
2032
2033 let mut engine = Engine::new(EngineConfig::default());
2034 engine.register_strategy(strategy);
2035 engine.register_instrument(instrument_meta(&instrument));
2036 engine.register_exchange(mock.clone() as Arc<dyn Exchange>);
2037
2038 let mut runner = EngineRunner::new(
2039 engine,
2040 RunnerConfig {
2041 min_poll_delay_ms: 1,
2042 quote_poll_interval_ms: 1,
2043 cleanup_delay_ms: 0,
2044 ..Default::default()
2045 },
2046 );
2047 runner.add_exchange(mock.clone() as Arc<dyn Exchange>);
2048 runner.add_instrument(instrument);
2049
2050 timeout(Duration::from_secs(2), runner.run())
2051 .await
2052 .expect("runner should complete after deferred retries");
2053
2054 assert_eq!(accepted.load(Ordering::SeqCst), 3);
2055 assert_eq!(rejected.load(Ordering::SeqCst), 0);
2056
2057 let placed = mock.placed_orders().await;
2058 assert_eq!(placed.len(), 3);
2059 assert_eq!(placed[0].client_id, ClientOrderId::new("batch-1"));
2060 assert_eq!(placed[1].client_id, ClientOrderId::new("batch-2"));
2061 assert_eq!(placed[2].client_id, ClientOrderId::new("later-single"));
2062 }
2063}