Skip to main content

bot_engine/
runner.rs

1//! Engine runner - polling loop and event dispatch.
2//!
3//! This module contains the main event loop that:
4//! 1. Polls exchanges for fills and quotes
5//! 2. Synthesizes canonical events
6//! 3. Dispatches events to strategies
7//! 4. Executes strategy commands
8//! 5. Syncs fills to upstream API for PnL tracking
9
10use 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/// Individual fill record for backtest results (serialized to frontend)
48#[derive(Debug, Clone, Serialize)]
49pub struct BacktestFill {
50    /// Fill timestamp in milliseconds.
51    pub ts_ms: i64,
52    /// Fill price as a decimal string.
53    pub price: String,
54    /// Fill quantity as a decimal string.
55    pub qty: String,
56    /// Fill side.
57    pub side: String,
58    /// Legacy field: fee asset/currency.
59    pub fee: String,
60    /// Additive numeric fee amount for consumers that need fee math.
61    pub fee_amount: String,
62    /// Fee currency.
63    pub fee_currency: String,
64}
65
66/// Final position and PnL summary for one tracked instrument.
67#[derive(Debug, Clone, Serialize)]
68pub struct BacktestPositionSummary {
69    /// Instrument ID.
70    pub instrument: String,
71    /// Final signed position quantity.
72    pub final_position_qty: String,
73    /// Average entry price, if any position remains.
74    pub avg_entry_price: Option<String>,
75    /// Realized PnL as a decimal string.
76    pub realized_pnl: String,
77    /// Unrealized PnL as a decimal string.
78    pub unrealized_pnl: Option<String>,
79    /// Total fees as a decimal string.
80    pub total_fees: String,
81    /// Net PnL as a decimal string.
82    pub net_pnl: String,
83}
84
85/// Backtest result summary for JSON output
86#[derive(Debug, Clone, Serialize)]
87pub struct BacktestResult {
88    /// Serialized fill records.
89    pub fills: Vec<BacktestFill>,
90    /// Number of fills.
91    pub trade_count: usize,
92    /// Calculated performance metrics.
93    pub metrics: PerformanceMetrics,
94    /// Benchmark context.
95    pub benchmark: PerformanceBenchmark,
96    /// Equity curve.
97    pub equity_curve: Vec<BacktestEquityPoint>,
98    /// Reconstructed closed trades.
99    pub closed_trades: Vec<BacktestClosedTrade>,
100    /// Legacy single-instrument final position quantity.
101    pub final_position_qty: String,
102    /// Legacy single-instrument average entry price.
103    pub avg_entry_price: Option<String>,
104    /// Legacy single-instrument realized PnL.
105    pub realized_pnl: String,
106    /// Legacy single-instrument unrealized PnL.
107    pub unrealized_pnl: Option<String>,
108    /// Total fees as a decimal string.
109    pub total_fees: String,
110    /// Total traded volume as a decimal string.
111    pub total_volume: String,
112    /// Net PnL as a decimal string.
113    pub net_pnl: String,
114    #[serde(default)]
115    /// Position summaries by instrument.
116    pub positions: Vec<BacktestPositionSummary>,
117    /// Strategy or runner exit reason.
118    pub exit_reason: Option<String>,
119}
120
121/// Message from polling tasks to the main loop
122#[derive(Debug)]
123pub enum PollResult {
124    /// User fills were polled from an exchange.
125    Fills {
126        /// Exchange instance.
127        instance: ExchangeInstance,
128        /// Fills returned by the exchange.
129        fills: Vec<Fill>,
130    },
131    /// Quotes were polled from an exchange.
132    Quotes {
133        /// Exchange instance.
134        instance: ExchangeInstance,
135        /// Quotes returned by the exchange.
136        quotes: Vec<Quote>,
137    },
138    /// Exchange health changed.
139    ExchangeHealth {
140        /// Exchange instance.
141        instance: ExchangeInstance,
142        /// New health state.
143        health: ExchangeHealth,
144    },
145    /// Polling task returned an error.
146    Error {
147        /// Exchange instance.
148        instance: ExchangeInstance,
149        /// Error message.
150        error: String,
151    },
152}
153
154/// Runner configuration
155#[derive(Debug, Clone)]
156pub struct RunnerConfig {
157    /// Minimum delay between polls (ms)
158    pub min_poll_delay_ms: u64,
159    /// Initial backoff delay (ms)
160    pub initial_backoff_ms: u64,
161    /// Max backoff delay (ms)
162    pub max_backoff_ms: u64,
163    /// Backoff multiplier
164    pub backoff_multiplier: f64,
165    /// Quote polling interval (ms) - separate from fills
166    pub quote_poll_interval_ms: u64,
167    /// Cleanup delay after strategy stop (ms) - time to wait for cleanup commands to complete
168    pub cleanup_delay_ms: u64,
169    /// Performance metrics mode label included in sync/backtest payloads.
170    pub metrics_mode: String,
171    /// Strategy-scoped starting USDC capital used for return/APR/equity metrics.
172    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, // 5 seconds for cleanup
184            metrics_mode: "live".to_string(),
185            metrics_starting_balance_usdc: None,
186        }
187    }
188}
189
190/// Trading statistics for meta logging (Poll mode)
191#[derive(Debug, Default, Clone)]
192pub struct TradingStats {
193    /// Total orders placed
194    pub orders_placed: u32,
195    /// Total orders filled
196    pub orders_filled: u32,
197    /// Total volume traded (notional)
198    pub volume_traded: Decimal,
199    /// Total fees paid
200    pub total_fees: Decimal,
201    /// Realized PnL (from closed positions)
202    pub realized_pnl: Decimal,
203    /// Last meta log time
204    pub last_meta_log_ms: i64,
205}
206
207/// Engine runner - drives the main event loop
208pub 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    /// Flag to indicate graceful shutdown requested by strategy
216    should_shutdown: bool,
217    /// Shutdown reason (from strategy stop or external signal)
218    shutdown_reason: Option<String>,
219    /// Optional trade syncer for upstream API syncing (Poll mechanism)
220    #[cfg(feature = "native")]
221    trade_syncer: Option<Box<dyn TradeSync>>,
222    /// Optional account syncer for upstream API syncing (Snapshot mechanism)
223    #[cfg(feature = "native")]
224    account_syncer: Option<Box<dyn AccountSync>>,
225    /// Current mid price for syncing (updated from quotes)
226    current_mid_price: Option<Decimal>,
227    /// Trading statistics for meta logging
228    stats: TradingStats,
229    /// Shared metrics tracker used by backtests and upstream sync payloads
230    performance_tracker: PerformanceTracker,
231    /// Poll guard for fills (per-exchange backoff + error classification)
232    fills_guard: PollGuard,
233    /// Poll guard for quotes (per-exchange backoff + error classification)
234    quotes_guard: PollGuard,
235    /// Commands delayed by Hyperliquid address-based cumulative action limits.
236    deferred_action_limit_commands: VecDeque<DeferredActionLimitCommand>,
237}
238
239impl EngineRunner {
240    /// Create an engine runner around a configured engine.
241    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    /// Set trade syncer configuration for upstream API syncing
273    #[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    /// Set trade syncer directly (for external configuration or testing)
288    #[cfg(feature = "native")]
289    pub fn set_trade_syncer(&mut self, syncer: Box<dyn TradeSync>) {
290        self.trade_syncer = Some(syncer);
291    }
292
293    /// Set account syncer configuration for upstream API syncing (Snapshot mechanism)
294    #[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    /// Set account syncer directly (for external configuration or testing)
309    #[cfg(feature = "native")]
310    pub fn set_account_syncer(&mut self, syncer: Box<dyn AccountSync>) {
311        self.account_syncer = Some(syncer);
312    }
313
314    /// Add an exchange to poll
315    pub fn add_exchange(&mut self, exchange: Arc<dyn Exchange>) {
316        let instance = exchange.instance();
317        self.exchanges.insert(instance, exchange);
318    }
319
320    /// Add an instrument to poll quotes for
321    pub fn add_instrument(&mut self, instrument: InstrumentId) {
322        self.performance_tracker.set_instrument(instrument.clone());
323        self.instruments.push(instrument);
324    }
325
326    /// Get shutdown sender (for external shutdown trigger)
327    pub fn shutdown_handle(&self) -> mpsc::UnboundedSender<()> {
328        self.shutdown_tx.clone()
329    }
330
331    /// Get reference to the underlying Engine.
332    /// Use this after run() completes to access positions, PnL, and other state.
333    pub fn engine(&self) -> &Engine {
334        &self.engine
335    }
336
337    /// Get the shutdown reason if the engine was stopped by the strategy.
338    pub fn shutdown_reason(&self) -> Option<&str> {
339        self.shutdown_reason.as_deref()
340    }
341
342    /// Compute backtest results from engine state.
343    /// Call after run() completes to get summary statistics.
344    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        // Compute volume from fills
351        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        // Convert fills to serializable format
385        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    /// Run the main event loop
438    pub async fn run(&mut self) {
439        tracing::info!("Starting engine runner...");
440
441        // Initialize all exchanges (validate connections, vault ownership, etc.)
442        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                // Use the existing shutdown path: stop strategies → cleanup
448                let stop_cmds = self.engine.stop_strategies();
449                self.execute_commands(stop_cmds).await;
450                return;
451            }
452        }
453
454        // Start strategies
455        let start_cmds = self.engine.start_strategies();
456        self.execute_commands(start_cmds).await;
457
458        // Take shutdown receiver
459        let mut shutdown_rx = self.shutdown_rx.take().expect("run called twice");
460
461        // Backtest completion detection constant
462        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            // Log every iteration in WASM for debugging
474            #[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            // Check for shutdown from strategy stop
481            if self.should_shutdown {
482                tracing::info!("Strategy requested shutdown");
483                break;
484            }
485
486            // Check for external shutdown signal
487            match shutdown_rx.try_next() {
488                Ok(Some(_)) | Ok(None) => {
489                    tracing::info!("Shutdown signal received");
490                    break;
491                }
492                Err(_) => {} // Channel empty
493            }
494
495            self.process_deferred_action_limit_commands().await;
496
497            // Snapshot exchanges for thread safety
498            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            // Determine sync mechanism from strategy
505            let sync_mechanism = self.engine.sync_mechanism();
506
507            // Poll all exchanges based on sync mechanism
508            for (instance, exchange) in &exchanges_snapshot {
509                match sync_mechanism {
510                    bot_core::SyncMechanism::Poll => {
511                        // Incremental: poll fills via PollGuard
512                        match self
513                            .fills_guard
514                            .execute(|| exchange.poll_user_fills(None))
515                            .await
516                        {
517                            PollOutcome::Data(fills) => {
518                                // Update health to active
519                                self.engine
520                                    .set_exchange_health(instance, ExchangeHealth::Active);
521
522                                // Process fills
523                                for fill in fills {
524                                    // Only dispatch fills we can attribute to a client_id (either directly via cloid
525                                    // or via exchange_order_id -> client_id mapping).
526                                    let client_id = self.resolve_fill_client_id(&fill);
527                                    if client_id.is_none() {
528                                        // Still add to syncer even if we can't attribute to client_id
529                                        // (might be from a different source)
530                                        #[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                                    // Apply fill to local order state (dedupe + completion detection)
538                                    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                                    // Add fill to syncer for upstream API sync
547                                    #[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                                    // Update trading stats
563                                    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 => {} // backoff already applied by guard
569                            PollOutcome::Degraded(_) | PollOutcome::Fatal(_) => {
570                                self.engine
571                                    .set_exchange_health(instance, ExchangeHealth::Halted);
572                            }
573                        }
574                    }
575                    bot_core::SyncMechanism::Snapshot => {
576                        // Absolute: poll account state via PollGuard
577                        // AccountState doesn't impl HasItems (single result, not a collection),
578                        // so we wrap it in a Vec for the guard and unwrap on the other side.
579                        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                                // Update health to active
590                                self.engine
591                                    .set_exchange_health(instance, ExchangeHealth::Active);
592
593                                // Update engine positions from snapshot
594                                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                                // Sync to backend via AccountSyncer
612                                #[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 => {} // backoff already applied by guard
641                            PollOutcome::Degraded(_) | PollOutcome::Fatal(_) => {
642                                self.engine
643                                    .set_exchange_health(instance, ExchangeHealth::Halted);
644                            }
645                        }
646                    }
647                }
648            }
649
650            // Poll quotes via PollGuard
651            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                                // Update engine quote state
664                                self.engine.update_quote(quote.clone());
665
666                                // Store mid price for syncing
667                                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                                // Create and dispatch quote event
677                                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 => {} // backoff already applied by guard
695                        PollOutcome::Degraded(_) | PollOutcome::Fatal(_) => {
696                            self.engine
697                                .set_exchange_health(instance, ExchangeHealth::Halted);
698                        }
699                    }
700                }
701            }
702
703            // Backtest completion detection: ONLY when min_poll_delay_ms == 0 (backtest mode)
704            // Live bots (delay > 0) must never exit from empty quote polls — API hiccups are transient.
705            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            // Periodic trade sync to upstream API
718            #[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            // Periodic meta log (every 30 seconds)
742            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                    // Poll mode: full stats
792                    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                    // Snapshot mode: just position
806                    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            // Minimum delay between loops (skip entirely for backtesting when delay is 0)
819            if self.config.min_poll_delay_ms > 0 {
820                sleep(Duration::from_millis(self.config.min_poll_delay_ms)).await;
821            } else {
822                // Yield to event loop without timer overhead (crucial for WASM backtesting)
823                crate::compat::yield_now().await;
824            }
825        }
826
827        // Stop strategies on shutdown (calls on_stop for any not yet stopped)
828        let stop_cmds = self.engine.stop_strategies();
829        self.execute_commands(stop_cmds).await;
830
831        // Wait for cleanup commands to complete (e.g., CancelAll orders)
832        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        // Final sync to upstream API on shutdown
841        #[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        // Output backtest results as JSON to stdout if we have instruments
869        // This allows supurr_cli (and other callers) to parse results
870        if let Some(first_instrument) = self.instruments.first() {
871            let results = self.get_backtest_results(first_instrument);
872            // Print JSON to stdout on a single line for easy parsing
873            if let Ok(json) = serde_json::to_string(&results) {
874                println!("{}", json);
875                // Flush stdout explicitly — large JSON can be truncated if the
876                // process exits before the buffered writer drains to the pipe.
877                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        // Apply fill to order manager (dedupe).
1036        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        // Calculate net_qty FIRST: for spot BUY, deduct fee if fee is in base asset
1048        // This must happen before position update so position reflects actual holdings
1049        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                // Spot BUY: fee is deducted from received base asset
1055                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        // Update position using NET qty (actual holdings after fee)
1071        self.engine
1072            .apply_position_fill(&fill.instrument, fill.side, net_qty, fill.price);
1073
1074        // Track fee from fill
1075        self.engine
1076            .apply_fill_fee(&fill.instrument, fill.fee.amount);
1077
1078        // Emit OrderFilled
1079        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        // Record fill in engine's fill history (for backtesting/reporting)
1093        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        // Emit OrderCompleted if order is now complete.
1100        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                    // Strategy stop is handled internally by the engine (on_stop already called)
1142                    tracing::info!("Strategy {} stopped: {}", stop.strategy_id.0, stop.reason);
1143                    // Capture the actual stop reason from strategy
1144                    self.shutdown_reason = Some(stop.reason.clone());
1145                    // Signal graceful shutdown
1146                    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        // Create/track the order locally before submitting (so cancels can resolve instrument).
1212        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        // Build OrderInput for the batch API
1221        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                // Get the first (and only) result
1236                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                        // Update trading stats
1247                        self.stats.orders_placed += 1;
1248
1249                        // Always emit OrderAccepted first
1250                        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                        // For Snapshot strategies with IOC fill, also emit OrderCompleted
1259                        // For Poll strategies, fills come from userFills polling
1260                        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                                // Emit OrderAccepted first, then OrderCompleted
1266                                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    /// Execute a batch of place orders in a single API call
1325    async fn execute_place_batch(&mut self, orders: Vec<PlaceOrder>) -> Vec<Event> {
1326        if orders.is_empty() {
1327            return Vec::new();
1328        }
1329
1330        // All orders should be for the same exchange
1331        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        // Build order inputs and track orders locally
1351        let mut order_inputs: Vec<OrderInput> = Vec::with_capacity(orders.len());
1352        let mut cmd_metadata: Vec<(PlaceOrder, bool)> = Vec::with_capacity(orders.len()); // (cmd, valid)
1353
1354        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                    // Create/track the order locally before submitting
1364                    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        // Collect events for invalid orders first
1396        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        // Execute batch place_orders
1420        match exchange.place_orders(&order_inputs).await {
1421            Ok(results) => {
1422                // Match results with valid commands
1423                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                                // For Snapshot strategies with IOC orders, emit OrderCompleted from fill info
1443                                // For Poll strategies, skip this - fills come from userFills polling
1444                                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                // Reject all valid orders on error
1509                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/// Spawn the runner as a background task
1636#[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/// Spawn the runner as a background task with optional trade syncer and account syncer
1647#[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    // Configure trade syncer if provided (for Poll strategies)
1659    if let Some(syncer_cfg) = syncer_config {
1660        runner = runner.with_syncer(syncer_cfg);
1661    }
1662
1663    // Configure account syncer if provided (for Snapshot strategies)
1664    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}